Skip to content

Unauthenticated cross-tenant and hidden-item disclosure via Query.node in the public Trust Center API

Low
SachaProbo published GHSA-w23w-f7v2-625w Jul 9, 2026

Package

gomod go.probo.inc/probo (Go)

Affected versions

<= 0.224.0

Patched versions

0.224.0

Description

Unauthenticated cross-tenant / hidden-item disclosure via Query.node in the public Trust Center API

AI-assistance disclosure: Found and drafted with the assistance of a generative-AI tool
(Anthropic Claude / "Claude Code"); all code references and the runtime PoC were reviewed and
executed by the human reporter against the real, unmodified Probo code before submission.

  • Component: pkg/server/api/trust/v1/base_resolvers.go:43-157 (Query.node) and the underlying
    pkg/trust/*_service.go Get methods.
  • Version: probod v0.222.2 (a3b65a644), default configuration.
  • Class: CWE-639 (Authorization Bypass Through User-Controlled Key) / CWE-284.
  • Severity (proposed): Moderate — CVSS 3.1 AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:N/A:N ≈ 4.0.
    (Same GID-precondition class the vendor accepted and paid as Moderate for GHSA-c74x-79w6-63jh — but
    here it is unauthenticated.)
  • Confidence: High — defect verified in source; cross-tenant read reproduced with a runtime PoC.

Summary

The public Trust Center GraphQL API exposes Query.node(id:) with
@authentication(required: OPTIONAL) — an unauthenticated internet visitor to any published
trust center can call it. Six of its type branches — Organization, Framework, Audit,
ThirdParty (subprocessor), TrustCenter, and TrustCenterReference — resolve the object with a
tenant scope derived from the client-supplied GID itself (coredata.NewScopeFromObjectID(id))
and load it with a bare Get(scope, id) that performs no comparison to the current trust
center's OrganizationID and no showOnTrustCenter/visibility check. (The Document and File
branches, by contrast, correctly pass trustCenter.OrganizationID and check visibility.)

As a result an attacker who possesses a target GID of one of these six types can read:

  • items hidden from the trust center of the visited org (the list resolvers apply
    showOnTrustCenter=true / visibility filters; node→Get applies none), and
  • objects belonging to any other organization (including orgs with no published trust center),
    because the tenant scope is taken from the attacker's GID rather than the visited org.

Root cause (source)

pkg/server/api/trust/v1/base_resolvers.go:

func (r *queryResolver) Node(ctx context.Context, id gid.GID) (Node, error) {
	scope := coredata.NewScopeFromObjectID(id)          // tenant taken from the attacker's GID
	switch id.EntityType() {
	case coredata.OrganizationEntityType:
		organization, err := trustService.Organizations.Get(ctx, scope, id)   // no org-match, no visibility
	...
	case coredata.FrameworkEntityType:
		framework, err := trustService.Frameworks.Get(ctx, scope, id)          // "
	case coredata.AuditEntityType:
		audit, err := trustService.Audits.Get(ctx, scope, id)                  // "
	case coredata.ThirdPartyEntityType:
		thirdParty, err := trustService.ThirdParties.Get(ctx, scope, id)       // "
	case coredata.TrustCenterEntityType:
		trustCenter, err := trustService.TrustCenters.Get(ctx, scope, id)      // "
	case coredata.TrustCenterReferenceEntityType:
		reference, err := trustService.TrustCenterReferences.Get(ctx, scope, id) // "
	case coredata.DocumentEntityType:
		trustCenter := compliancepage.CompliancePageFromContext(ctx)
		document, err := trustService.Documents.Get(ctx, scope, trustCenter.OrganizationID, id) // CORRECT: org-bound + ErrDocumentNotVisible
	case coredata.FileEntityType:
		trustCenter := compliancepage.CompliancePageFromContext(ctx)
		file, err := trustService.Reports.Get(ctx, scope, trustCenter.OrganizationID, id)       // CORRECT
	}
}

NewScopeFromObjectID (coredata/scope.go:65-77) builds a scope whose only SQL predicate is
tenant_id = @tenant_id, with the tenant taken from the GID. Since the six branches never compare
to CompliancePageFromContext(ctx).OrganizationID nor apply the visibility predicate the list paths
use (audit_service.go NewAuditTrustCenterFilter, third_party_service.go showOnTrustCenter=true),
the tenant isolation is sourced from attacker input and no other guard remains.

Reachability (hop-by-hop)

  1. Query.node carries @authentication(required: OPTIONAL) (trust/v1/graphql/base.graphql:32);
    the /graphql route runs session middleware in optional mode. An unauthenticated visitor to
    any published (Active) trust center — reached by slug, GID, or custom-domain SNI via the
    compliance-page id/SNI middleware — can call it. No @nda, no login. Gate absent.
  2. Attacker calls node(id:<target GID of one of the six types>){ ... on Organization { name description websiteUrl email headquarterAddress } ... on Subprocessor { name description category websiteUrl privacyPolicyUrl countries } ... }.
  3. The branch loads by NewScopeFromObjectID(id) → returns the object from its own tenant, with no
    org-match and no visibility filter. Data returned.

Impact

Two confidentiality gains over the intended public surface, unauthenticated:

  • Hidden-from-trust-center items of the visited org (deliberately withheld from the public list)
    are returned by GID.
  • Cross-tenant objects of these six types from any other organization are returned. Leaked
    fields include Organization name/description/websiteUrl/email/headquarterAddress; Subprocessor
    name/description/category/websiteUrl/privacyPolicyUrl/countries; Framework/Audit/Reference
    names + metadata; and a fully-resolvable TrustCenter object whose child connections enumerate
    that org's items.

Proof of concept (executed, benign)

Runtime PoC on embedded PostgreSQL (PG18.3) with full coredata migrations: two organizations in
two different tenants (org A visited, org B victim). Replicating the resolver's exact primitive,
trustService.Frameworks.Get(coredata.NewScopeFromObjectID(fwB.ID), fwB.ID) returned org B's
SecretComplianceFrameworkB cross-tenant, and Organizations.Get leaked org B's confidential
description + email. Negative control: the same fwB.ID loaded under the visited org A's scope
(coredata.NewScope(tenantA)) returned ErrResourceNotFound — proving tenant isolation exists and
is defeated only because the resolver sources the scope from the attacker-supplied GID. Benign
sentinel values; local self-owned instance; PoC removed after running.

Adversarial re-read

  • "node requires authentication." Refuted: @authentication(required: OPTIONAL) — anonymous
    callers pass.
  • "The service Get enforces org/visibility." Refuted for these six branches: they call
    Get(scope, id) with no org argument and no visibility filter, unlike the Document/File branches.
  • "Tenant scoping protects it." The scope's tenant is attacker-controlled (NewScopeFromObjectID),
    so scoping is vacuous here — reproduced by the negative control.
  • Residual (honest, reflected in AC:H): exploitation needs a valid target GID (tenant bits +
    48-bit random); blind guessing is infeasible, so realistic acquisition is via a leaked/observed
    GID (referrer, logs, a published sibling GID pivoted to a hidden one, a stamped artifact). This is
    the same precondition class the vendor accepted and paid as Moderate for PROBO-IDOR-001 — here
    strengthened by being unauthenticated.

Preconditions

  • A published (Active) trust center exists to serve as the entry context (any org's).
  • Attacker possesses a target GID of one of the six affected types. No authentication, no membership.

Remediation

In each of the six branches, build the scope from
compliancepage.CompliancePageFromContext(ctx).OrganizationID (as the Document/File branches do) and
enforce the same showOnTrustCenter/visibility predicate the list resolvers use; return NotFound on
mismatch.

Related LEAD (for the vendor to confirm — not claimed here)

The @nda directive (pkg/server/api/trust/v1/nda_directive.go:36-39) returns next(ctx) when the
caller is unauthenticated (identity == nil). Because the trust Query is unauthenticated, an
anonymous visitor is never subjected to the NDA gate on @nda-decorated types (Document, Framework,
AuditReport, Audit, Subprocessor, TrustCenterReference, TrustCenterFile and their connections). If
NDA-gated content is intended to be withheld from anonymous visitors, this is a broader disclosure
than the node IDOR and should fail closed (require an identity + a completed signature). We have
not established the intended anonymous-visibility model, so we flag it for your confirmation rather
than claim it.

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

CVE ID

CVE-2026-76080

Weaknesses

Improper Access Control

The product does not restrict or incorrectly restricts access to a resource from an unauthorized actor. Learn more on MITRE.

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

Credits