Get the user from Spring Security with @AuthenticationPrincipal, never from request parameters, and scope every repository query with tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin). Detail, list and export then return only rows the caller may read; map an empty result to 404. In @PostAuthorize or @PostFilter expressions, compare the tenant before any hasRole() shortcut.
- List every controller method and repository query that reads invoices, including list, search, export, download and @Scheduled jobs.
- Let Spring Security authenticate every request (anyRequest().authenticated()) and receive the user with @AuthenticationPrincipal. Remove @RequestParam, @PathVariable or @RequestBody fields that carry a user ID, tenant ID or role.
- Pass the principal into the repository and add tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin) to each query, binding the values with a MapSqlParameterSource. With Spring Data, the SecurityEvaluationContextExtension from spring-security-data lets an @Query refer to the principal, as in ?#{ principal?.id }.
- Map an empty result to 404, so a missing invoice and someone else's look the same.
- For bulk endpoints, compare the rows the scoped query returned with the IDs that were asked for, and refuse the whole request if any are missing.
- Add MockMvc regression tests that sign in with SecurityMockMvcRequestPostProcessors.authentication(...) as a colleague, as a member of another tenant and as a billing admin of another tenant.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
@Override
public Invoice find(InvoiceUser user, long id) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = :id""";
return jdbc.query(sql, Map.of("id", id), INVOICE).stream().findFirst().orElse(null);
}
@Override
public List<Invoice> list(InvoiceUser user) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE tenant_id = :tenant
ORDER BY id""";
return jdbc.query(sql, Map.of("tenant", user.tenantId()), INVOICE);
}
@Override
public List<Invoice> export(InvoiceUser user, List<Long> ids) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (:ids)
ORDER BY id""";
return jdbc.query(sql, Map.of("ids", ids), INVOICE);
}With IMPL=vulnerable, 6 of 15 tests fail (6 exploit tests).
The fix
// Members read their own invoices; billing admins read every invoice in their own
// tenant. The tenant test sits outside the parentheses, so it binds admins too:
// tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin)
private static MapSqlParameterSource scope(InvoiceUser user) {
return new MapSqlParameterSource()
.addValue("tenant", user.tenantId())
.addValue("user", user.id())
.addValue("isBillingAdmin", user.isBillingAdmin());
}
@Override
public Invoice find(InvoiceUser user, long id) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = :id AND tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin)""";
return jdbc.query(sql, scope(user).addValue("id", id), INVOICE).stream().findFirst().orElse(null);
}
@Override
public List<Invoice> list(InvoiceUser user) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin)
ORDER BY id""";
return jdbc.query(sql, scope(user), INVOICE);
}
@Override
public List<Invoice> export(InvoiceUser user, List<Long> ids) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (:ids)
AND tenant_id = :tenant AND (owner_id = :user OR :isBillingAdmin)
ORDER BY id""";
return jdbc.query(sql, scope(user).addValue("ids", ids), INVOICE);
}With IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
@Override
@PostAuthorize("returnObject == null or returnObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')")
public Invoice find(InvoiceUser user, long id) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = :id""";
return jdbc.query(sql, Map.of("id", id), INVOICE).stream().findFirst().orElse(null);
}
@Override
@PostFilter("filterObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')")
public List<Invoice> list(InvoiceUser user) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
ORDER BY id""";
return jdbc.query(sql, INVOICE);
}
@Override
@PostFilter("filterObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')")
public List<Invoice> export(InvoiceUser user, List<Long> ids) {
String sql = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (:ids)
ORDER BY id""";
return jdbc.query(sql, Map.of("ids", ids), INVOICE);
}With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).
Routes: the user comes from Spring Security, not the request
// Spring Security supplies the signed-in user from the security context. Nothing
// here reads a user, tenant or role from the request.
@GetMapping
List<Invoice> list(@AuthenticationPrincipal InvoiceUser user) {
return invoices.list(user);
}
@GetMapping("/{id}")
ResponseEntity<Invoice> find(@PathVariable long id, @AuthenticationPrincipal InvoiceUser user) {
Invoice invoice = invoices.find(user, id);
// Missing and not yours get the same answer, so IDs cannot be probed.
return invoice == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(invoice);
}
@GetMapping("/export")
ResponseEntity<String> export(@RequestParam List<Long> ids, @AuthenticationPrincipal InvoiceUser user) {
List<Long> unique = ids.stream().distinct().toList();
if (unique.isEmpty() || unique.size() > MAX_EXPORT_IDS) {
return ResponseEntity.badRequest().body("ids must be 1 to " + MAX_EXPORT_IDS + " invoice IDs");
}
List<Invoice> rows = invoices.export(user, unique);
// All or nothing: one missing or unreadable ID refuses the whole export, so a
// partial file is never mistaken for the full one.
if (rows.size() != unique.size()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok().contentType(TEXT_CSV).body(toCsv(rows));
}The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: other users, other tenants and other tenants' admins must get nothing.
// Denied looks exactly like missing: 404 and none of the invoice's data.
private void assertHidden(String userId, String path, String number) throws Exception {
MvcResult result = request(userId, path);
assertThat(result.getResponse().getStatus()).as("status, body %s", body(result)).isEqualTo(404);
assertThat(body(result)).doesNotContain(number);
}
@Test
void exploitMemberCannotReadAColleaguesInvoiceInTheSameTenant() throws Exception {
assertHidden("alice", "/invoices/502", "INV-502");
}
@Test
void exploitMemberOfAnotherTenantCannotReadTheInvoice() throws Exception {
assertHidden("erin", "/invoices/501", "INV-501");
}
@Test
void exploitBillingAdminCannotReadAnInvoiceInAnotherTenant() throws Exception {
assertHidden("cara", "/invoices/601", "INV-601");
}
@Test
void exploitMembersListLeavesOutColleaguesInvoices() throws Exception {
assertThat(listedIds("alice")).containsExactly(501, 503);
}
@Test
void exploitBillingAdminsListStaysInsideTheirTenant() throws Exception {
assertThat(listedIds("cara")).containsExactly(501, 502, 503);
}
@Test
void exploitExportThatIncludesAColleaguesInvoiceIsRefusedWhole() throws Exception {
assertHidden("alice", "/invoices/export?ids=501,502", "INV-502");
}
@Test
void exploitBillingAdminsExportIntoAnotherTenantIsRefusedWhole() throws Exception {
assertHidden("cara", "/invoices/export?ids=502,601", "INV-601");
}- @PostAuthorize("returnObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')") is this card's wrong implementation: hasRole() never looks at the tenant. Write returnObject.tenantId() == #user.tenantId() and (returnObject.ownerId() == #user.id() or hasRole('BILLING_ADMIN')); SpEL's and binds tighter than or.
- @PostFilter filters in memory after the query has loaded every tenant's rows. Spring Security's documentation suggests filtering in the data layer instead, and only a scoped query keeps paging and counts correct.
- By default, method security works through a Spring AOP proxy, so a call from one method of a bean to another method of the same bean is not checked.
- A denied @PostAuthorize throws AccessDeniedException, which Spring Security turns into 403. Map it to 404 if denied and missing invoices must look the same, as this card's controller does.
- Spring Data JPA: findById(id) ignores ownership. Declare a method such as findByIdAndTenantId, or an @Query that carries the full scope, and keep the tenant in every derived query.
- A tenant ID from a header or a path variable is only a selector. If you accept one, check that the signed-in user belongs to that tenant before using it.
- Random IDs such as UUIDs make guessing harder, but leaked links, logs and browser history still expose them; the check is still required.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authorization/java-spring does this for you).
- Against your own API, sign in as two members of one tenant and as a billing admin of another tenant, then replay each other's invoice IDs on the detail, list and export routes. Every request that crosses a boundary must get a 404 or only in-scope rows.
- Do not expect a scanner to find this: no Semgrep registry rule or CodeQL query models object-level authorization for Spring, Semgrep's OWASP Top 10 and Java packs reported nothing on the vulnerable code, and Find Security Bugs' SPRING_ENDPOINT only lists controllers for review.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/owasp-top-ten | No findings on src/main/java: the vulnerable, fixed and wrong code look the same to every rule in these packs.Checked: Semgrep 1.180.0 (CE) |
| Find Security Bugs (SpotBugs plugin) | SPRING_ENDPOINTFound Spring endpoint | An inventory aid, not a detector: it reports every Spring controller as remotely reachable so that someone reviews it. It cannot tell whether a method compares the invoice's tenant and owner.Checked: Bug pattern listed on the Find Security Bugs site; not executed |
No rule targets object-level authorization in Spring. In semgrep-rules develop at 9bbf021 the only Java rule tagged CWE-639, CWE-862 or CWE-863 checks <http-method> in web.xml security constraints. In github/codeql main at b5b8165, the only Java query with one of those tags is the experimental PermissiveDotRegex.ql (permissive regular expressions in security path matchers).
| Component | Version |
|---|---|
| Language | Java 21 |
| Runtime | OpenJDK 21.0.12.1 (Homebrew), Maven 3.9.16 |
| Framework | Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Security 7.1.1, Spring MVC) |
| org.springframework.boot:spring-boot-starter-parent | 4.1.1 |
| org.springframework.boot:spring-boot-starter-webmvc | 4.1.1 |
| org.springframework.boot:spring-boot-starter-security | 4.1.1 (Spring Security 7.1.1) |
| org.springframework.boot:spring-boot-starter-jdbc | 4.1.1 |
| org.xerial:sqlite-jdbc | 3.53.4.0 (SQLite 3.53.4) |
| org.springframework.boot:spring-boot-starter-webmvc-test | 4.1.1 |
| org.springframework.boot:spring-boot-starter-security-test | 4.1.1 (spring-security-test 7.1.1) |
| org.junit.jupiter:junit-jupiter (managed by Spring Boot) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authorization/java-springfolder. Maven runs with JDK 21 (set JAVA_HOME).
mvn -B -q test# Expected: every test passes
IMPL=fixed mvn -B -o -q test
# Expected: an exploit test fails
IMPL=vulnerable mvn -B -o -q test
# Expected: a test fails
IMPL=wrong mvn -B -o -q testEach run writes a JUnit report to target/surefire-reports/TEST-*.xml. From the top folder of the download, node run-all.mjs missing-authorization/java-spring runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (arm64)
- Toolchain
- java OpenJDK 21.0.12.1
- maven 3.9.16
- sqlite 3.53.4
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
- Test results
- IMPL=fixed 15/15 pass; IMPL=vulnerable 6/15 fail (6 exploit); IMPL=wrong 3/15 fail (3 exploit); run offline in a no-network sandbox, after installs with warm and with empty package caches
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP Authorization Cheat Sheet
- OWASP Multi-Tenant Security Cheat Sheet
- Spring Security: Method Security (@PostAuthorize, @PostFilter)
- Spring Security: Spring Data integration (queries scoped to the current user)
- Spring Security: @AuthenticationPrincipal
- Spring Security: Testing with MockMvc, running as a user
- Spring Framework: Using NamedParameterJdbcTemplate