Skip to content

Commit 08c6e80

Browse files
notSumit25claude
andcommitted
fix(security): require admin for global-config endpoints in SetupController
SetupController carried no authorization at all — no @PreAuthorize, no assertCan* call. SecurityConfig reaches it only through .anyRequest().authenticated(), so every authenticated user, the lowest role included, could call all seven endpoints. POST /setup/llm-config writes the LLM provider, endpoint and API key into system_config, and LlmConfigResolver.resolveChat() reads the database tier before the environment tier. A low-privilege write therefore silently overrode a correctly configured production install, with no restart and no error, sending every chat turn, schema and query result to a host of the caller's choosing. POST /setup/llm-config/test passed the same unvalidated endpoint into RestClient.baseUrl, giving SSRF into cloud metadata and internal services. /setup/organization and /setup/complete were also open. The class javadoc said "All other endpoints require an authenticated user" — true, and exactly the trap: authentication is not authorization. Gate the class with @PreAuthorize("hasRole('ADMIN')") and mark the two genuinely pre-login routes @PreAuthorize("permitAll()"). No legitimate flow breaks: /onboarding is already wrapped in <ProtectedRoute> and the first account is created by the bootstrap endpoint as an ADMIN, so the wizard is only ever reached by a caller who passes the gate. ConnectionScopedAuthorizationSafetyTest could not catch this — it keys on a connectionId or a @PathVariable ...Id and this controller has neither, so the suite stayed green over a critical hole. GlobalConfigAuthorizationSafetyTest covers that shape. Its own detector was mutation-tested: the first version used source.indexOf("@PreAuthorize"), which the import line and a javadoc mention both satisfy, so deleting the real gate left the test passing. Verified: test fails before the fix naming all five endpoints, passes after, and fails again when the gate is removed. 23 safety tests green, mvn compile clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 38ad33a commit 08c6e80

4 files changed

Lines changed: 365 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,6 +839,26 @@ it against a real database — not a theoretical hardening pass.
839839
`noteId`, `taskId`), resolve the owning connection first via that service's
840840
`getConnectionId(id)` and assert on the result. Do not skip the check because the
841841
path has no `connectionId` in it.
842+
- **A global-config controller is invisible to the connection-scoped scanner.**
843+
`SetupController` shipped with **no authorization at all** — no `@PreAuthorize`, no
844+
`assertCan*` — so any authenticated user (lowest role included) could `POST
845+
/setup/llm-config` and repoint the org's LLM endpoint and API key. `LlmConfigResolver`
846+
reads the **database tier before the environment tier**, so that write silently
847+
overrode a correctly configured install with no restart, sending every chat turn,
848+
schema and query result to a host of the caller's choosing; `/setup/llm-config/test`
849+
passed the same unvalidated URL into `RestClient.baseUrl` (SSRF → cloud metadata).
850+
Its javadoc said "All other endpoints require an authenticated user" — true, and
851+
exactly the trap. `ConnectionScopedAuthorizationSafetyTest` could not see it: that
852+
scanner keys on a `connectionId` or a `@PathVariable …Id`, and this controller has
853+
**neither**, so the suite stayed green over a critical hole. Fixed with a class-level
854+
`@PreAuthorize("hasRole('ADMIN')")` plus explicit `permitAll()` on the two genuinely
855+
pre-login routes (`/status`, `/initialize`); the wizard itself is already behind
856+
`<ProtectedRoute>` and reached only by the bootstrap-created admin, so no flow breaks.
857+
`GlobalConfigAuthorizationSafetyTest` now covers this shape. **Its own detector had to
858+
be mutation-tested**: the first version used `source.indexOf("@PreAuthorize")`, which
859+
the `import` line and a javadoc mention both satisfy, so deleting the real gate left
860+
the test green — match an annotation at the start of a line instead. See
861+
`docs/security/2026-09-10-setup-controller-authorization.md`.
842862
- **An endpoint with no connection scope at all is admin-only.**
843863
`POST /brain/column-values/embed-all` spans every connection, so it carries
844864
`@PreAuthorize("hasRole('ADMIN')")` — it cannot be authorized against one

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import lombok.extern.slf4j.Slf4j;
99
import org.springframework.beans.factory.annotation.Value;
1010
import org.springframework.http.ResponseEntity;
11+
import org.springframework.security.access.prepost.PreAuthorize;
1112
import org.springframework.web.bind.annotation.*;
1213
import org.springframework.web.client.RestClient;
1314

@@ -17,14 +18,32 @@
1718
/**
1819
* REST API for the first-run onboarding wizard.
1920
*
20-
* <p>The {@code GET /setup/status} endpoint is publicly accessible (no auth required)
21-
* so the frontend can detect first-run before login. All other endpoints require
22-
* an authenticated user.
21+
* <p>{@code GET /setup/status} and {@code POST /setup/initialize} are publicly accessible
22+
* (both sit in {@code SecurityConfig}'s permitAll set) because they run before any account
23+
* exists: the frontend probes {@code /status} to detect a first run, and {@code /initialize}
24+
* performs it. Both are annotated {@code @PreAuthorize("permitAll()")} so the class-level
25+
* gate below does not close the onboarding flow.
26+
*
27+
* <p>Every other endpoint here writes <em>installation-wide</em> configuration and is
28+
* admin-only. This class previously carried no authorization at all, and its javadoc said
29+
* "All other endpoints require an authenticated user" — true, and exactly the trap:
30+
* authentication is not authorization. Any authenticated user, the lowest role included,
31+
* could POST {@code /setup/llm-config} and repoint the organization's LLM endpoint and API
32+
* key. {@code LlmConfigResolver} reads the database tier before the environment tier, so
33+
* that write silently overrode a correctly configured install with no restart, sending every
34+
* chat turn, schema and query result to a host of the caller's choosing;
35+
* {@code /setup/llm-config/test} issued a server-side request to the same unvalidated URL.
36+
*
37+
* <p>These settings belong to no single connection, so they cannot be authorized against
38+
* one — which is why {@code ConnectionScopedAuthorizationSafetyTest} could not see the gap.
39+
* {@code GlobalConfigAuthorizationSafetyTest} covers this shape and fails the build if a
40+
* handler here loses its gate.
2341
*/
2442
@RestController
2543
@RequestMapping("/setup")
2644
@RequiredArgsConstructor
2745
@Slf4j
46+
@PreAuthorize("hasRole('ADMIN')")
2847
public class SetupController {
2948

3049
/** Provider id used when the caller does not name one. */
@@ -57,6 +76,7 @@ public class SetupController {
5776
// ── GET /setup/status ─────────────────────────────────────────────────────
5877

5978
/** Returns setup completion state. Public endpoint — no auth required. */
79+
@PreAuthorize("permitAll()")
6080
@GetMapping("/status")
6181
public SetupStatusResponse getStatus() {
6282
boolean hasOrgInfo = systemConfigService.get("setup.org.name")
@@ -87,6 +107,7 @@ public SetupStatusResponse getStatus() {
87107
* and returns a JWT so the caller is immediately logged in.
88108
* Returns 409 if any user already exists (setup already done).
89109
*/
110+
@PreAuthorize("permitAll()")
90111
@PostMapping("/initialize")
91112
public ResponseEntity<Map<String, Object>> initialize(
92113
@RequestBody InitializeRequest request) {
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
package com.dbaagent.controller;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.io.IOException;
6+
import java.nio.file.Files;
7+
import java.nio.file.Path;
8+
import java.util.ArrayList;
9+
import java.util.LinkedHashSet;
10+
import java.util.List;
11+
import java.util.Set;
12+
import java.util.regex.Matcher;
13+
import java.util.regex.Pattern;
14+
import java.util.stream.Stream;
15+
16+
import static org.assertj.core.api.Assertions.assertThat;
17+
18+
/**
19+
* Closes the gap {@link ConnectionScopedAuthorizationSafetyTest} structurally cannot cover.
20+
*
21+
* <p>That scanner only inspects handlers carrying a {@code connectionId} or a
22+
* {@code @PathVariable …Id}, because its job is per-connection tenancy. A controller that
23+
* writes <em>global</em> configuration has neither, so it is invisible to it — and the suite
24+
* still reports green.
25+
*
26+
* <p>{@code SetupController} lived in exactly that blind spot. It had no authorization of any
27+
* kind, so any authenticated user — the lowest role included — could POST
28+
* {@code /setup/llm-config} and repoint the organization's LLM endpoint and API key.
29+
* {@code LlmConfigResolver} reads the database tier before the environment tier, so that write
30+
* silently overrode a correctly configured install with no restart, sending every chat turn,
31+
* schema and query result to an attacker-chosen host. {@code /setup/llm-config/test} took the
32+
* same unvalidated URL and issued a server-side request to it.
33+
*
34+
* <p>Its class javadoc read "All other endpoints require an authenticated user" — true, and
35+
* precisely the trap: authentication is not authorization. This test encodes the rule CLAUDE.md
36+
* already states in prose, that an endpoint with no connection scope at all is admin-only.
37+
*
38+
* <p>Scanned as source text on purpose, matching {@link CorsAllowlistSafetyTest}: the guarantee
39+
* wanted is that no <em>future</em> global-config controller ships unguarded, whatever it is
40+
* named, and a text scan needs no database, Redis or LLM credentials to run.
41+
*/
42+
class GlobalConfigAuthorizationSafetyTest {
43+
44+
private static final Path CONTROLLERS = Path.of("src/main/java/com/dbaagent/controller");
45+
46+
/**
47+
* Controllers that write installation-wide configuration — settings that belong to no single
48+
* connection and therefore cannot be authorized against one. Every handler in these, apart
49+
* from the explicitly public ones below, must be admin-gated.
50+
*/
51+
private static final Set<String> GLOBAL_CONFIG_CONTROLLERS = Set.of("SetupController.java");
52+
53+
/**
54+
* Endpoints that must stay reachable without authentication, with the reason each is safe.
55+
*
56+
* <p>Both are genuinely pre-login: the frontend calls {@code /setup/status} to detect a
57+
* first run before any account exists, and {@code /setup/initialize} performs that first
58+
* run. They are also listed in {@code SecurityConfig}'s permitAll set, so gating them here
59+
* would break the onboarding flow rather than harden it.
60+
*/
61+
private static final Set<String> PUBLIC_BY_DESIGN = Set.of(
62+
"/status", // pre-login first-run probe; returns no secret (api keys are masked)
63+
"/initialize" // performs the first run itself, before any user exists to authorize
64+
);
65+
66+
/**
67+
* Handler mappings only. {@code @RequestMapping} is deliberately excluded: on these
68+
* controllers it is the class-level base path, not an endpoint, and counting it produced a
69+
* phantom offender ({@code "/setup"}) on the first run of this test.
70+
*/
71+
private static final Pattern MAPPING = Pattern.compile(
72+
"@(?:Get|Post|Put|Delete|Patch)Mapping\\s*\\(\\s*(?:value\\s*=\\s*)?\"([^\"]*)\"");
73+
74+
private static final Pattern CLASS_DECLARATION = Pattern.compile("\\bpublic\\s+class\\b");
75+
76+
/**
77+
* A {@code @PreAuthorize} annotation in annotation position: at the start of a line,
78+
* indentation aside. Excludes the {@code import} line and any javadoc mention, both of
79+
* which precede the class declaration and would otherwise read as a gate.
80+
*/
81+
private static final Pattern CLASS_LEVEL_PREAUTHORIZE = Pattern.compile(
82+
"(?m)^[ \\t]*@PreAuthorize\\s*\\(");
83+
84+
private static List<Path> globalConfigControllers() throws IOException {
85+
try (Stream<Path> files = Files.walk(CONTROLLERS)) {
86+
return files.filter(Files::isRegularFile)
87+
.filter(p -> GLOBAL_CONFIG_CONTROLLERS.contains(p.getFileName().toString()))
88+
.toList();
89+
}
90+
}
91+
92+
/**
93+
* True when a real {@code @PreAuthorize} annotation guards the class itself.
94+
*
95+
* <p>Matched at the start of a line, and the {@code import} line is excluded explicitly.
96+
* A plain {@code source.indexOf("@PreAuthorize")} is not good enough and was wrong here on
97+
* the first attempt: the import and a javadoc mention of the annotation both sit above the
98+
* class declaration, so deleting the actual gate still left the check returning true. That
99+
* was caught by removing the annotation and watching this test stay green — the same
100+
* false-negative the existing ConnectionScopedAuthorizationSafetyTest is noted to have.
101+
*/
102+
private static boolean hasClassLevelPreAuthorize(String source) {
103+
Matcher declaration = CLASS_DECLARATION.matcher(source);
104+
if (!declaration.find()) {
105+
return false;
106+
}
107+
String beforeClass = source.substring(0, declaration.start());
108+
return CLASS_LEVEL_PREAUTHORIZE.matcher(beforeClass).find();
109+
}
110+
111+
/** The body of one handler: from its mapping annotation to the start of the next one. */
112+
private static String handlerBody(String source, int mappingStart) {
113+
Matcher next = MAPPING.matcher(source);
114+
int end = source.length();
115+
if (next.find(mappingStart + 1)) {
116+
end = next.start();
117+
}
118+
return source.substring(mappingStart, end);
119+
}
120+
121+
@Test
122+
void everyGlobalConfigEndpointIsAdminGatedUnlessPublicByDesign() throws IOException {
123+
List<String> offenders = new ArrayList<>();
124+
125+
for (Path file : globalConfigControllers()) {
126+
String source = Files.readString(file);
127+
boolean classGated = hasClassLevelPreAuthorize(source);
128+
129+
Matcher mappings = MAPPING.matcher(source);
130+
while (mappings.find()) {
131+
String route = mappings.group(1);
132+
if (PUBLIC_BY_DESIGN.contains(route)) {
133+
continue;
134+
}
135+
boolean methodGated = handlerBody(source, mappings.start()).contains("@PreAuthorize");
136+
if (!classGated && !methodGated) {
137+
offenders.add(file.getFileName() + " \"" + route + "\"");
138+
}
139+
}
140+
}
141+
142+
assertThat(offenders)
143+
.as("""
144+
Global-configuration endpoints reachable by any authenticated user.
145+
146+
These write installation-wide settings, so they cannot be authorized against a \
147+
connection and ConnectionScopedAuthorizationSafetyTest cannot see them. Guard the \
148+
controller with @PreAuthorize("hasRole('ADMIN')") and keep genuinely pre-login \
149+
routes in PUBLIC_BY_DESIGN with a reason.
150+
151+
Unguarded: %s""".formatted(offenders))
152+
.isEmpty();
153+
}
154+
155+
/**
156+
* The public exemptions are only safe while they stay pre-login. If {@code /status} or
157+
* {@code /initialize} ever stops being listed in {@code SecurityConfig}'s permitAll set, the
158+
* exemption above is masking a real gate rather than reflecting one, so fail and force a
159+
* re-read of both files together.
160+
*/
161+
@Test
162+
void publicByDesignRoutesAreStillPermitAllInSecurityConfig() throws IOException {
163+
String securityConfig = Files.readString(
164+
Path.of("src/main/java/com/dbaagent/config/SecurityConfig.java"));
165+
166+
Set<String> missing = new LinkedHashSet<>();
167+
for (String route : PUBLIC_BY_DESIGN) {
168+
if (!securityConfig.contains("\"/setup" + route + "\"")) {
169+
missing.add("/setup" + route);
170+
}
171+
}
172+
173+
assertThat(missing)
174+
.as("""
175+
PUBLIC_BY_DESIGN exempts these from the admin gate because SecurityConfig \
176+
permits them without authentication. They are no longer in that permitAll list, \
177+
so the exemption no longer describes reality — either restore them there or \
178+
drop them from PUBLIC_BY_DESIGN so they get gated.
179+
180+
No longer permitAll: %s""".formatted(missing))
181+
.isEmpty();
182+
}
183+
}

0 commit comments

Comments
 (0)