GraphQL Auth Bypass on Observability Platform Allows Cross-Tenant Read and Write
The Risk
A small handful of internal-only data calls on the company's observability product were missing the login check. Anyone on the internet, with no account, could read the shared library of error tags belonging to every customer of the product, search it by topic, and add new records to it that everyone would then see. The leaked data contained other customers' internal server addresses, security-test markers planted by external researchers, and snippets of malicious code that those customers' apps had logged.
The Vulnerability
Three GraphQL resolvers on the observability product's production endpoint had no authentication middleware applied. The endpoint URL was leaked by the company's own anonymous bootstrap config, and its CORS allowlist was bound to the main application origin only, so the calls also worked from a browser console on the regular login page.
The unauthenticated resolvers were:
error_tags- a bulk read returning the entire cross-tenanterror_tagscorpus, 15,915 records spanning every customer of the product.match_error_tag(query)- a vector-embedding semantic search across the same corpus, allowing topic-based queries like "password", "AWS", or "secret" to surface relevant content.createErrorTag- a mutation that persistently inserts a new record into the shared corpus.
The defect was per-resolver, not global. Dozens of other resolvers on the same endpoint correctly rejected unauthenticated callers with errors such as ProjectAuth requires AccountID in context. The auth middleware existed; it was specifically not applied on these three paths.
A secondary information disclosure surfaced a raw Postgres unique-constraint violation message including the index name and SQLSTATE code to anonymous callers when a duplicate title was submitted.
The Attack
- Open the product's regular login page. Open browser console.
- Recover the GraphQL endpoint URL from the unauthenticated bootstrap config (a single fetch to an anonymous config route).
- POST a GraphQL introspection query. Schema introspection is enabled (186 queries and 82 mutations enumerable unauthenticated), making it trivial to find resolvers that lack auth.
- Run
{ error_tags { id title description } }. Receive 15,915 records back. The corpus contains:- Other customers' internal private network addresses captured by their error tracking, e.g.
http://10.36.29.236:35579/.... - Live malicious script payloads logged by other customers' applications (
<script>,<img onerror=>). - Security-test markers planted by external researchers on other customers' projects (HackerOne, Bugcrowd markers,
webhook.siteout-of-band probe URLs).
- Other customers' internal private network addresses captured by their error tracking, e.g.
- Run a semantic search like
{ match_error_tag(query:"password") { id title description } }to surface content related to arbitrary topics, including path-traversal payloads logged by other customers' apps. - Run the
createErrorTagmutation with a timestamped marker title. Receive a fresh record id back. Re-query the corpus to confirm persistence; the marker is still queryable a day later.
The Impact
- Cross-tenant read of 15,915 records from every customer of the product. Leaked content included internal private network addresses, live web-attack payloads, and markers planted by other security researchers.
- Semantic search across the same corpus allowed an attacker to query by concept and surface sensitive content from arbitrary tenants without knowing the literal strings.
- Persistent write into a shared cross-tenant dataset. Verified by creating record id
1142444from a clean unauthenticated shell and re-reading it the next day. An attacker could pollute the corpus with misleading entries that would be displayed to every customer of the product. - Database error disclosure leaked Postgres internals (index name, SQLSTATE code) to anonymous callers, useful for further targeted probing.
Remediation
- Apply the existing project-auth middleware to the three exposed resolvers (
error_tags,match_error_tag,createErrorTag). The middleware already enforces account context everywhere else on the same endpoint, so the fix is narrowly scoped. - Audit every resolver on the endpoint with a per-resolver auth-status enumeration to confirm no other accidental gaps remain.
- Catch database driver errors at the resolver boundary and return generic error messages to clients. Do not pass raw Postgres error text and SQLSTATE codes to anonymous callers.
- Disable GraphQL schema introspection on the production endpoint, or gate it behind authenticated access. Introspection on an internal endpoint made the resolver-by-resolver auth-status enumeration trivial.
- Identify and remove the marker records left during reproduction. All carry the title prefix
bb-unauth-write-probe-DELETE-ME-with a UNIX-timestamp suffix.