{"id":"GHSA-p5vg-v7mj-f6q4","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\u003cimg width=\"1100\" height=\"830\" alt=\"1_live_poc_evidence\" src=\"https://github.com/user-attachments/assets/b2d18d3f-8e70-40ec-ad7f-7f0ac1fdebe0\" /\u003e\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: \u003c= v26.6.2 (latest release at time of report). No patched version\navailable.","aliases":["CVE-2026-81505","GO-2026-6523"],"modified":"2026-09-28T17:10:51.160702463Z","published":"2026-09-18T17:17:45Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-09-18T17:17:45Z","nvd_published_at":null,"cwe_ids":["CWE-639"]},"references":[{"type":"WEB","url":"https://github.com/frain-dev/convoy/security/advisories/GHSA-p5vg-v7mj-f6q4"},{"type":"WEB","url":"https://github.com/frain-dev/convoy/pull/2755"},{"type":"WEB","url":"https://github.com/frain-dev/convoy/commit/1cc67cd16fb1f8890cc83a3998d3f92dceb7fd06"},{"type":"PACKAGE","url":"https://github.com/frain-dev/convoy"},{"type":"WEB","url":"https://github.com/frain-dev/convoy/releases/tag/v26.6.8"}],"affected":[{"package":{"name":"github.com/frain-dev/convoy","ecosystem":"Go","purl":"pkg:golang/github.com/frain-dev/convoy"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.9.3-0.20260724092134-1cc67cd16fb1"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-p5vg-v7mj-f6q4/GHSA-p5vg-v7mj-f6q4.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N"}]}