{"id":"CVE-2026-81505","aliases":["GHSA-p5vg-v7mj-f6q4","GO-2026-6523"],"url":"https://o3.security/vulnerability/CVE-2026-81505","summary":"Convoy: Cross-Tenant Source IDOR Leaks Plaintext Message Broker Credentials","details":"## Summary\nfrain-dev/convoy (all versions up to and including v26.6.2, no patch\navailable) lets any authenticated caller who is authorized on at least one\nproject read ANY OTHER project's \"Source\" record by ID via\nGET /api/v1/projects/{projectID}/sources/{sourceID} -- regardless of whether\nthat Source actually belongs to the project named in the URL. The response\nincludes the Source's full PubSub broker configuration in plaintext\n(AMQP/Kafka/SQS/Google credentials), with no redaction.\n\n## Details\n`Handler.GetSource` (api/handlers/source.go) resolves the caller's\nauthorization against the {projectID} in the URL via retrieveProject(), then\ncalls `sources.Service.FindSourceByID(ctx, project.UID, sourceID)`\n(internal/sources/impl.go). That function accepts a projectID parameter but\nnever uses it:\n\n    func (s *Service) FindSourceByID(ctx context.Context, projectID, id string) (*datastore.Source, error) {\n        row, err := s.repo.FetchSourceByID(ctx, common.StringToPgText(id))\n        ...\n    }\n\nThe underlying SQL query (internal/sources/repo/queries.sql,\n`fetchSourceByID`) has no project_id predicate at all:\n\n    SELECT ... FROM convoy.sources AS s\n    LEFT JOIN convoy.source_verifiers sv ON s.source_verifier_id = sv.id\n    WHERE s.id = $1 AND s.deleted_at IS NULL\n\nSo the {projectID} in the URL only gates \"is the caller authorized to view\n*a* project\" -- it never re-validates that the fetched Source actually\nbelongs to that project. `LoadSourcesPaged` (the list endpoint) does\ncorrectly scope by project; only the single-item GetSource lookup is\naffected. `SourceResponse{*datastore.Source}` embeds the full database\nrecord with no redaction, so if the leaked Source is an AMQP/Kafka type, its\n`pub_sub.*.auth.password` field (a live, plaintext broker credential) is\nreturned verbatim.\n\n## Proof of Concept\n1. As a test account, create \"Victim Project\" and an AMQP Source in it\n   with a known broker password (verified against a real RabbitMQ broker,\n   not just stored -- Convoy performs a live connectivity check before\n   persisting an AMQP source).\n2. As the same or a different authenticated caller, create a completely\n   separate \"Attacker Project\".\n3. Call GET /api/v1/projects/{attacker_project_id}/sources/{victim_source_id}\n   -- i.e. a request whose URL and authorization check only ever resolve\n   the Attacker project.\n4. Verified in a Docker lab (convoy v26.6.2): the request returns 200 with\n   the Victim project's full Source object, including\n   pub_sub.amqp.auth.password matching the secret set in step 1 exactly.\n   The response's own `project_id` field is the Victim project's id, never\n   the Attacker project id used in the URL/authorization check.\n5. Negative control: an otherwise-identical request for a made-up source id\n   under the same Attacker project returns 404, confirming this is a real,\n   ID-specific hit and not a blanket-200 endpoint.\n\n(Community Edition license-gates org_limit=1/user_limit=1, so this PoC used\none account with two projects rather than two separate companies -- the\nvulnerable query performs no project-ownership check regardless of license\ntier, so a licensed multi-org deployment has the identical exposure between\ngenuinely different tenants.)\n\n<img width=\"1100\" height=\"830\" alt=\"1_live_poc_evidence\" src=\"https://github.com/user-attachments/assets/b2d18d3f-8e70-40ec-ad7f-7f0ac1fdebe0\" />\n\n\n## Impact\nAny authenticated user or project-scoped API key holder who has legitimate\naccess to at least one project on a Convoy instance can read any other\nproject's Source configuration by ID, including plaintext third-party\nmessage-broker credentials (AMQP/Kafka/SQS/Google PubSub). In a\nmulti-tenant deployment this is a direct cross-customer credential leak.\n\n## Fix\nAdd a project_id predicate to the fetchSourceByID query (and audit other\nsingle-item repository lookups reachable via a {projectID} route for the\nsame pattern), or re-check `source.ProjectID == project.UID` in the handler\nbefore returning the record.\n\n## Affected / Patched\nAffected: <= v26.6.2 (latest release at time of report). No patched version\navailable.","published":"2026-09-18T16:21:17.278Z","modified":"2026-10-28T02:30:23.956712855Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Go","name":"github.com/frain-dev/convoy","fixedVersion":"0.9.3-0.20260724092134-1cc67cd16fb1"}],"fix":{"url":"https://github.com/frain-dev/convoy/commit/1cc67cd16fb1f8890cc83a3998d3f92dceb7fd06","label":"frain-dev/convoy@1cc67cd"},"references":[{"type":"WEB","url":"https://github.com/frain-dev/convoy/releases/tag/v26.6.8"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/81xxx/CVE-2026-81505.json"},{"type":"ADVISORY","url":"https://github.com/frain-dev/convoy/security/advisories/GHSA-p5vg-v7mj-f6q4"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-81505"},{"type":"FIX","url":"https://github.com/frain-dev/convoy/commit/1cc67cd16fb1f8890cc83a3998d3f92dceb7fd06"},{"type":"FIX","url":"https://github.com/frain-dev/convoy/pull/2755"},{"type":"PACKAGE","url":"https://github.com/frain-dev/convoy"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-10-28T02:30:23.956712855Z"}}