Skip to content

Commit bb01cd0

Browse files
notSumit25claude
andcommitted
fix(security): authorize the operated-on row, not a caller-supplied id
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>
1 parent 8b391c7 commit bb01cd0

8 files changed

Lines changed: 253 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,25 @@ it against a real database — not a theoretical hardening pass.
10881088
while an allowed origin gets `200` + ACAO — so the annotation never had effect. It
10891089
still reads like an intentional hole to the next person.
10901090

1091+
- **A guard can be present and still authorize the wrong id.** Twelve endpoints across
1092+
`CodeScanController` (8), `CompanyKnowledgeController` (2) and `DashboardAlertController` (2)
1093+
each called `assertCanManageConnectionContent` — so the scanner passed them — but on an id
1094+
unrelated to what they operated on. The scan endpoints checked a `@RequestParam connectionId`
1095+
the caller owns while deleting/reading a `@PathVariable sourceId`/`jobId`/`suggestionId`
1096+
belonging to another tenant; `CompanyKnowledgeController.update` wrapped its guard in
1097+
`if (entry.getConnectionId() != null)`, so omitting the field skipped it entirely;
1098+
`DashboardAlertController` authorized `dashboardId` but acted on an `alertId` never bound to
1099+
it. Verified live: `analyst` (grant on one connection) deleted another tenant's scan source,
1100+
deleted and overwrote a knowledge entry, and deleted an alert — **200 unpatched, 404 patched**
1101+
in every case. **Resolve the row's own connection and assert on that**`CodeScanService`
1102+
`findConnectionIdForSource/Job/Suggestion`, `CompanyKnowledgeService.findConnectionIdForEntry`,
1103+
`DashboardAlertService.findDashboardIdForAlert` — never a caller-supplied id. **404 for both
1104+
"unknown" and "not yours"** so the endpoint is not an existence oracle; **`bulk-decide` checks
1105+
every id and fails on one that resolves to nothing**. The `ConnectionScopedAuthorizationSafetyTest`
1106+
`AUTHORIZED` regex is presence-only (does *an* assert appear), not dataflow (does it assert on
1107+
the *right* id), so it cannot catch this class — the live cross-tenant test is the real guard.
1108+
See `docs/security/2026-09-16-wrong-id-authorization.md`.
1109+
10911110
### MCP & CLI Release Rules
10921111

10931112
**Whenever you add, rename, or remove an MCP tool or a CLI subcommand, you MUST update all of these in the same commit — they are agent-facing surfaces and drift silently breaks discoverability:**

backend/src/main/java/com/dbaagent/controller/CodeScanController.java

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ public ResponseEntity<CodeScanSource> updateFocus(
5757
@RequestParam("connectionId") String connectionId,
5858
@RequestBody UpdateFocusRequest body
5959
) {
60-
accessControlService.assertCanManageConnectionContent(connectionId);
60+
assertCanManageSource(sourceId);
6161
return ResponseEntity.ok(
6262
codeScanService.updateFocus(sourceId, body == null ? null : body.focus())
6363
);
@@ -72,7 +72,7 @@ public ResponseEntity<List<CodeScanSource>> listSources(@RequestParam("connectio
7272
@DeleteMapping("/sources/{sourceId}")
7373
public ResponseEntity<Void> deleteSource(@PathVariable String sourceId,
7474
@RequestParam("connectionId") String connectionId) {
75-
accessControlService.assertCanManageConnectionContent(connectionId);
75+
assertCanManageSource(sourceId);
7676
codeScanService.deleteSource(sourceId);
7777
return ResponseEntity.ok().build();
7878
}
@@ -86,7 +86,7 @@ public ResponseEntity<CodeScanJob> startScan(
8686
@RequestParam(value = "focus", required = false) String focus,
8787
@RequestParam("file") MultipartFile file
8888
) throws IOException {
89-
accessControlService.assertCanManageConnectionContent(connectionId);
89+
assertCanManageSource(sourceId);
9090
return ResponseEntity.ok(
9191
codeScanService.startScan(sourceId, file, focus, accessControlService.getCurrentUsername())
9292
);
@@ -95,7 +95,7 @@ public ResponseEntity<CodeScanJob> startScan(
9595
@GetMapping("/jobs/{jobId}")
9696
public ResponseEntity<CodeScanJob> getJob(@PathVariable String jobId,
9797
@RequestParam("connectionId") String connectionId) {
98-
accessControlService.assertCanManageConnectionContent(connectionId);
98+
assertCanManageJob(jobId);
9999
return codeScanService.getJob(jobId)
100100
.map(ResponseEntity::ok)
101101
.orElseGet(() -> ResponseEntity.notFound().build());
@@ -104,14 +104,14 @@ public ResponseEntity<CodeScanJob> getJob(@PathVariable String jobId,
104104
@GetMapping("/sources/{sourceId}/jobs")
105105
public ResponseEntity<List<CodeScanJob>> listJobs(@PathVariable String sourceId,
106106
@RequestParam("connectionId") String connectionId) {
107-
accessControlService.assertCanManageConnectionContent(connectionId);
107+
assertCanManageSource(sourceId);
108108
return ResponseEntity.ok(codeScanService.recentJobs(sourceId));
109109
}
110110

111111
@GetMapping(value = "/jobs/{jobId}/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
112112
public SseEmitter streamJob(@PathVariable String jobId,
113113
@RequestParam("connectionId") String connectionId) {
114-
accessControlService.assertCanManageConnectionContent(connectionId);
114+
assertCanManageJob(jobId);
115115
return codeScanService.subscribeJob(jobId);
116116
}
117117

@@ -137,7 +137,7 @@ public ResponseEntity<CodeKnowledgeSuggestion> decide(
137137
@RequestParam("connectionId") String connectionId,
138138
@RequestBody DecideRequest body
139139
) {
140-
accessControlService.assertCanManageConnectionContent(connectionId);
140+
assertCanManageSuggestion(suggestionId);
141141
return ResponseEntity.ok(
142142
codeScanService.decide(
143143
suggestionId,
@@ -153,10 +153,15 @@ public ResponseEntity<Map<String, Object>> bulkDecide(
153153
@RequestParam("connectionId") String connectionId,
154154
@RequestBody BulkDecideRequest body
155155
) {
156-
accessControlService.assertCanManageConnectionContent(connectionId);
157156
if (body == null || body.ids() == null || body.ids().isEmpty()) {
158157
return ResponseEntity.badRequest().body(Map.of("error", "ids required"));
159158
}
159+
// Every id must resolve to a connection the caller can manage, and an id that
160+
// resolves to nothing fails too — otherwise an unknown id rides into an otherwise
161+
// valid batch. connectionId is accepted for wire compatibility but not trusted.
162+
for (String suggestionId : body.ids()) {
163+
assertCanManageSuggestion(suggestionId);
164+
}
160165
var result = codeScanService.bulkDecide(
161166
body.ids(),
162167
body.decision(),
@@ -171,6 +176,25 @@ public ResponseEntity<Map<String, Object>> bulkDecide(
171176
return ResponseEntity.ok(payload);
172177
}
173178

179+
// Resolve the row's own connection and authorise against that — never the caller-supplied
180+
// connectionId, which the caller may legitimately own while the id targets another tenant.
181+
// 404 for both "no such id" and "not yours", so the endpoint is not an existence oracle,
182+
// matching the rule DashboardWorkspaceService.assertCanReadDashboard already follows.
183+
private void assertCanManageSource(String sourceId) {
184+
accessControlService.assertCanManageConnectionContentOrNotFound(
185+
codeScanService.findConnectionIdForSource(sourceId).orElse(null), "Scan source");
186+
}
187+
188+
private void assertCanManageJob(String jobId) {
189+
accessControlService.assertCanManageConnectionContentOrNotFound(
190+
codeScanService.findConnectionIdForJob(jobId).orElse(null), "Scan job");
191+
}
192+
193+
private void assertCanManageSuggestion(String suggestionId) {
194+
accessControlService.assertCanManageConnectionContentOrNotFound(
195+
codeScanService.findConnectionIdForSuggestion(suggestionId).orElse(null), "Suggestion");
196+
}
197+
174198
private static CodeKnowledgeSuggestion.Status parseStatus(String s) {
175199
try {
176200
return CodeKnowledgeSuggestion.Status.valueOf(s.toUpperCase(Locale.ROOT));

backend/src/main/java/com/dbaagent/controller/CompanyKnowledgeController.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,11 @@ public ResponseEntity<CompanyKnowledgeEntry> create(@RequestBody CompanyKnowledg
3636
public ResponseEntity<CompanyKnowledgeEntry> update(
3737
@PathVariable String entryId,
3838
@RequestBody CompanyKnowledgeEntry entry) {
39-
if (entry.getConnectionId() != null && !entry.getConnectionId().isBlank()) {
40-
accessControlService.assertCanManageConnectionContent(entry.getConnectionId());
41-
}
39+
// Authorise against the stored entry's connection, unconditionally. The old check ran
40+
// only when the body carried a connectionId, so omitting that field skipped it and let
41+
// any authenticated user edit any tenant's entry. The body's connectionId is never
42+
// trusted here; updateEntry already refuses to change it.
43+
assertCanManageEntry(entryId);
4244
if (entry.getCreatedBy() == null || entry.getCreatedBy().isBlank()) {
4345
entry.setCreatedBy(accessControlService.getCurrentUsername());
4446
}
@@ -48,9 +50,15 @@ public ResponseEntity<CompanyKnowledgeEntry> update(
4850
@DeleteMapping("/{entryId}")
4951
public ResponseEntity<Void> delete(
5052
@PathVariable String entryId,
51-
@RequestParam String connectionId) {
52-
accessControlService.assertCanManageConnectionContent(connectionId);
53+
@RequestParam(required = false) String connectionId) {
54+
// The connectionId param was never compared to the entry being deleted; authorise on
55+
// the entry's own connection instead. Accepted for wire compatibility, not trusted.
56+
assertCanManageEntry(entryId);
5357
companyKnowledgeService.deleteEntry(entryId);
5458
return ResponseEntity.ok().build();
5559
}
60+
private void assertCanManageEntry(String entryId) {
61+
accessControlService.assertCanManageConnectionContentOrNotFound(
62+
companyKnowledgeService.findConnectionIdForEntry(entryId).orElse(null), "Knowledge entry");
63+
}
5664
}

backend/src/main/java/com/dbaagent/controller/DashboardAlertController.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import lombok.RequiredArgsConstructor;
1010
import lombok.extern.slf4j.Slf4j;
1111
import org.springframework.http.HttpStatus;
12+
import org.springframework.web.server.ResponseStatusException;
1213
import org.springframework.http.ResponseEntity;
1314
import org.springframework.web.bind.annotation.*;
1415

@@ -68,6 +69,7 @@ public ResponseEntity<Map<String, Object>> update(@PathVariable UUID dashboardId
6869
try {
6970
SavedDashboard dashboard = requireDashboard(dashboardId);
7071
accessControlService.assertCanManageConnectionContent(dashboard.getConnectionId());
72+
requireAlertOnDashboard(alertId, dashboardId);
7173
DashboardAlert updated = alertService.updateAlert(alertId, updates);
7274
return ResponseEntity.ok(Map.of("success", true, "alert", updated));
7375
} catch (IllegalArgumentException e) {
@@ -85,6 +87,7 @@ public ResponseEntity<Map<String, Object>> delete(@PathVariable UUID dashboardId
8587
try {
8688
SavedDashboard dashboard = requireDashboard(dashboardId);
8789
accessControlService.assertCanManageConnectionContent(dashboard.getConnectionId());
90+
requireAlertOnDashboard(alertId, dashboardId);
8891
alertService.deleteAlert(alertId);
8992
return ResponseEntity.ok(Map.of("success", true));
9093
} catch (org.springframework.web.server.ResponseStatusException e) {
@@ -100,6 +103,16 @@ public ResponseEntity<Map<String, Object>> delete(@PathVariable UUID dashboardId
100103
* membership gate applies to all of them at once. The connection check stays with
101104
* each caller because read and write paths need different assertions.
102105
*/
106+
// Bind the alertId to the dashboard we just authorised. Authorising the dashboard is only
107+
// half the check when a second id rides alongside it: 404 (not 403) for an unknown alert or
108+
// one under a different dashboard, so this cannot confirm another dashboard's alert exists.
109+
private void requireAlertOnDashboard(UUID alertId, UUID dashboardId) {
110+
UUID owner = alertService.findDashboardIdForAlert(alertId).orElse(null);
111+
if (owner == null || !owner.equals(dashboardId)) {
112+
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found");
113+
}
114+
}
115+
103116
private SavedDashboard requireDashboard(UUID dashboardId) {
104117
SavedDashboard dashboard = savedDashboardService.getDashboardById(dashboardId)
105118
.orElseThrow(() -> new IllegalArgumentException("Dashboard not found"));

backend/src/main/java/com/dbaagent/service/CompanyKnowledgeService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,17 @@ public CompanyKnowledgeEntry createEntry(CompanyKnowledgeEntry entry) {
404404
return annotateEntry(saved, schema);
405405
}
406406

407+
/**
408+
* The connection an entry belongs to, or empty if there is no such entry. The controller
409+
* authorises against this — not a caller-supplied connectionId that it never compares to
410+
* the entry, and that on the update path was only consulted when the caller chose to send
411+
* it, so omitting it skipped the check entirely.
412+
*/
413+
public java.util.Optional<String> findConnectionIdForEntry(String entryId) {
414+
return companyKnowledgeEntryRepository.findById(entryId)
415+
.map(CompanyKnowledgeEntry::getConnectionId);
416+
}
417+
407418
@Transactional
408419
public CompanyKnowledgeEntry updateEntry(String entryId, CompanyKnowledgeEntry request) {
409420
validate(request, false);

backend/src/main/java/com/dbaagent/service/DashboardAlertService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ public void deleteAlert(UUID alertId) {
8585
alertRepository.deleteById(alertId);
8686
}
8787

88+
/**
89+
* The dashboard an alert belongs to, or empty if there is no such alert. The controller
90+
* authorises the dashboard, then binds the alert to it with this — update/delete took an
91+
* alertId beside the dashboardId and acted on the alert without checking it belonged to the
92+
* authorised dashboard, so a dashboard you own paired with another tenant's alertId let you
93+
* repoint or delete their alert.
94+
*/
95+
public java.util.Optional<UUID> findDashboardIdForAlert(UUID alertId) {
96+
return alertRepository.findById(alertId).map(DashboardAlert::getDashboardId);
97+
}
98+
8899
private DashboardAlert requireAlert(UUID id) {
89100
return alertRepository.findById(id)
90101
.orElseThrow(() -> new IllegalArgumentException("Alert not found with id: " + id));

backend/src/main/java/com/dbaagent/service/codescan/CodeScanService.java

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,30 @@ public List<CodeScanSource> listSources(String connectionId) {
174174
return sourceRepository.findByConnectionIdAndActiveTrueOrderByCreatedAtDesc(connectionId);
175175
}
176176

177+
/**
178+
* The connection a scan source belongs to, or empty if there is no such source.
179+
*
180+
* <p>The controller authorises against this, not against a caller-supplied {@code
181+
* connectionId}. Every scan endpoint took a {@code @RequestParam connectionId} beside a
182+
* {@code @PathVariable sourceId}/{@code jobId} and asserted on the param — so a caller
183+
* passed a connection they own next to another tenant's source id, the assert passed, and
184+
* the operation hit a row they had no access to.
185+
*/
186+
public java.util.Optional<String> findConnectionIdForSource(String sourceId) {
187+
return sourceRepository.findById(sourceId).map(CodeScanSource::getConnectionId);
188+
}
189+
190+
/** The connection a scan job belongs to, or empty if there is no such job. */
191+
public java.util.Optional<String> findConnectionIdForJob(String jobId) {
192+
return jobRepository.findById(jobId).map(CodeScanJob::getConnectionId);
193+
}
194+
195+
/** The connection a suggestion belongs to, or empty if there is no such suggestion. */
196+
public java.util.Optional<String> findConnectionIdForSuggestion(String suggestionId) {
197+
return suggestionRepository.findById(suggestionId)
198+
.map(CodeKnowledgeSuggestion::getConnectionId);
199+
}
200+
177201
@Transactional
178202
public void deleteSource(String sourceId) {
179203
sourceRepository.findById(sourceId).ifPresent(s -> {

0 commit comments

Comments
 (0)