fix(security): authorize the operated-on row, not a caller-supplied id - #116
notSumit25 wants to merge 1 commit into
Conversation
Twelve endpoints each called assertCanManageConnectionContent — so the safety scanner passed them — but on an id unrelated to what they operated on. CodeScanController's 8 scan endpoints checked a @RequestParam connectionId the caller owns while acting on a @PathVariable sourceId/jobId/suggestionId belonging to another tenant. CompanyKnowledgeController.update wrapped its guard in `if (entry.getConnectionId() != null)`, so omitting the field skipped the check entirely and let any authenticated user overwrite any entry. Its delete checked a @RequestParam never compared to the entry. DashboardAlertController authorized dashboardId but then updated/deleted an alertId never bound to that dashboard. Reproduced live against the running stack, not inferred. analyst (a grant on one connection only) attacked rows on a connection they have no access to, by passing their own connection/dashboard beside the victim's row id: delete another tenant's scan source 200 unpatched -> 404 patched delete another tenant's knowledge entry 200 unpatched -> 404 patched overwrite an entry via PUT (no connId) 200 unpatched -> 404 patched delete an alert via own dashboard + id 200 unpatched -> 404 patched Legitimate access is unchanged: analyst deletes a source on their own granted connection (200), admin reads their own connection's rows (200). The fix resolves each row's own connection and authorizes against that: CodeScanService.findConnectionIdForSource/Job/Suggestion, CompanyKnowledgeService.findConnectionIdForEntry, DashboardAlertService.findDashboardIdForAlert — never a caller-supplied id. 404 for both "unknown" and "not yours" so the endpoint is not an existence oracle; bulk-decide checks every id and fails on one that resolves to nothing. The connectionId params are still accepted for wire compatibility but ignored. The ConnectionScopedAuthorizationSafetyTest AUTHORIZED check is presence-only (does an assert appear), not dataflow (does it assert on the right id), so it cannot catch this class; the live cross-tenant test is the real guard, and the suite still passes because the new helpers contain assertCan. 32 tests green, compile clean. Both users' password hashes and every planted row were restored to the prior state. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Independent hands-on QA — 11/11 PASS, including coverage the implementation testing skippedThis round deliberately went beyond re-running the four deletes I proved during implementation. It targeted the endpoints and claims I had not individually verified live: the read/write scan endpoints, Cross-tenant endpoints not previously exploited (analyst attacks QA Vault Copy via their own connection)
S5 — bulk-decide atomicity (the claim I made in code but had never run)Batch mixing analyst's own suggestion id + a victim id:
The victim id poisons the whole batch and it fails before applying any decision — analyst's own suggestion stays PENDING too. Atomic-fail, not fail-after-partial. This is the "an id that resolves to nothing/elsewhere fails too" property, proven live. All 11 scenarios
One honest test-harness correctionS4 first showed unpatched=400, which looked wrong. Investigating: my planted suggestion was missing S9 confirms the residual bug is pre-existingNon-existent Environment
Verdict: READY — zero blocking issues. Every one of the 12 endpoints blocks cross-tenant access (404) while preserving legitimate access (200), bulk-decide fails atomically, and no regression to the frontend contract. |
The problem
Twelve endpoints each called
assertCanManageConnectionContent— soConnectionScopedAuthorizationSafetyTestpassed them — but the check verified a different id than the one the endpoint operated on.Shape 1 — check the connection, operate on an unrelated row. Every scan endpoint:
The caller controls both — pass a connection you own (check passes) + another tenant's
sourceId(findByIdhas no ownership filter).Shape 2 — make the guard conditional on an attacker-controlled field.
CompanyKnowledgeController.updateguarded onlyif (entry.getConnectionId() != null), so omitting the field skips the check and overwrites any tenant's entry.Shape 3 — two path variables.
DashboardAlertControllerauthorizeddashboardIdcorrectly but acted on analertIdnever bound to that dashboard — pair a dashboard you own with another tenant'salertId.CodeScanControllersourceId/jobId/suggestionIdCompanyKnowledgeControllerPUT/DELETE /{entryId}entryIdDashboardAlertControllerPUT/DELETE /{alertId}alertIdReproduced live, not inferred
Two tenants:
analysthas a grant on Demo Shop only;adminowns QA Vault Copy. Victim rows planted on QA Vault Copy, thenanalystattacked each with their own connection/dashboard + the victim's row id:activet→fconnectionIdLegitimate access unchanged (also verified live):
analystdeletes a source on their own granted connection (200);adminreads QA Vault Copy's rows (200).The fix
Resolve the row's own connection and authorize against that:
CodeScanService.findConnectionIdForSource/Job/SuggestionCompanyKnowledgeService.findConnectionIdForEntry(asserts unconditionally now)DashboardAlertService.findDashboardIdForAlert(binds the alert to the authorized dashboard)404, not 403, for both "unknown" and "not yours" — not an existence oracle.
bulk-decidechecks every id and fails on one that resolves to nothing, so an unknown id can't ride into a valid batch.connectionIdparams still accepted for wire compat, ignored for the check.Why the scanner missed it
ConnectionScopedAuthorizationSafetyTest'sAUTHORIZEDcheck is presence-only — does an assert appear in the handler, not which id it verifies. All twelve contained an assert. A general dataflow scanner is a much larger undertaking than this fix; the durable guard is the live cross-tenant test (the standard the audit itself used). The safety suite still passes because the new helpers containassertCan....Verification
mvn compileDB restored: both password hashes byte-identical, all planted rows removed (tables back to empty).
Residual work (deliberately not here)
DashboardAlertControllerreturns 500, not 404, for a non-existentdashboardId—requireDashboardthrowsIllegalArgumentException, and the delete handler maps onlyResponseStatusException+ genericException. Surfaced by this QA but pre-existing and separate; deserves its own fix (map to 404, as the update handler already does).Write-up:
docs/security/2026-09-16-wrong-id-authorization.md🤖 Generated with Claude Code