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)
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.
- 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 } ... }.
- 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.
Unauthenticated cross-tenant / hidden-item disclosure via
Query.nodein the public Trust Center APIpkg/server/api/trust/v1/base_resolvers.go:43-157(Query.node) and the underlyingpkg/trust/*_service.goGetmethods.a3b65a644), default configuration.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.)
Summary
The public Trust Center GraphQL API exposes
Query.node(id:)with@authentication(required: OPTIONAL)— an unauthenticated internet visitor to any publishedtrust center can call it. Six of its type branches —
Organization,Framework,Audit,ThirdParty(subprocessor),TrustCenter, andTrustCenterReference— resolve the object with atenant 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 trustcenter's
OrganizationIDand noshowOnTrustCenter/visibility check. (TheDocumentandFilebranches, by contrast, correctly pass
trustCenter.OrganizationIDand check visibility.)As a result an attacker who possesses a target GID of one of these six types can read:
showOnTrustCenter=true/ visibility filters;node→Getapplies none), andbecause 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:NewScopeFromObjectID(coredata/scope.go:65-77) builds a scope whose only SQL predicate istenant_id = @tenant_id, with the tenant taken from the GID. Since the six branches never compareto
CompliancePageFromContext(ctx).OrganizationIDnor apply the visibility predicate the list pathsuse (
audit_service.goNewAuditTrustCenterFilter,third_party_service.goshowOnTrustCenter=true),the tenant isolation is sourced from attacker input and no other guard remains.
Reachability (hop-by-hop)
Query.nodecarries@authentication(required: OPTIONAL)(trust/v1/graphql/base.graphql:32);the
/graphqlroute runs session middleware in optional mode. An unauthenticated visitor toany 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.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 } ... }.NewScopeFromObjectID(id)→ returns the object from its own tenant, with noorg-match and no visibility filter. Data returned.
Impact
Two confidentiality gains over the intended public surface, unauthenticated:
are returned by GID.
fields include Organization
name/description/websiteUrl/email/headquarterAddress; Subprocessorname/description/category/websiteUrl/privacyPolicyUrl/countries; Framework/Audit/Referencenames + metadata; and a fully-resolvable
TrustCenterobject whose child connections enumeratethat 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'sSecretComplianceFrameworkBcross-tenant, andOrganizations.Getleaked org B's confidentialdescription + email. Negative control: the same
fwB.IDloaded under the visited org A's scope(
coredata.NewScope(tenantA)) returnedErrResourceNotFound— proving tenant isolation exists andis 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
noderequires authentication." Refuted:@authentication(required: OPTIONAL)— anonymouscallers pass.
Getenforces org/visibility." Refuted for these six branches: they callGet(scope, id)with no org argument and no visibility filter, unlike the Document/File branches.NewScopeFromObjectID),so scoping is vacuous here — reproduced by the negative control.
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
Remediation
In each of the six branches, build the scope from
compliancepage.CompliancePageFromContext(ctx).OrganizationID(as the Document/File branches do) andenforce the same
showOnTrustCenter/visibility predicate the list resolvers use; return NotFound onmismatch.
Related LEAD (for the vendor to confirm — not claimed here)
The
@ndadirective (pkg/server/api/trust/v1/nda_directive.go:36-39) returnsnext(ctx)when thecaller is unauthenticated (
identity == nil). Because the trustQueryis unauthenticated, ananonymous 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
nodeIDOR and should fail closed (require an identity + a completed signature). We havenot established the intended anonymous-visibility model, so we flag it for your confirmation rather
than claim it.