Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/opentaint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ jobs:

- uses: seqra/opentaint/github@github/v0
with:
rules-path: 'broken-access-control.yaml'
upload-sarif: 'true'
112 changes: 112 additions & 0 deletions broken-access-control.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Demo policy: access only your own notes.
# Approved guards bind an unchanged selector with @P; selector mutation is out of scope.
# Method security, the ownership helper, principal-scoped queries and fixed ownership are trusted.
# Authentication uses a method search; ownership rules track request parameters to raw APIs.
# Approved direct guards exempt their bound input; changes after authorization are not modeled.
rules:
- id: spring.missing-note-ownership
languages:
- java
severity: WARNING
message: Raw note access lacks an approved guard for this ID; review runtime ownership checks
metadata:
category: authorization-standard
license: MIT
short-description: Note ownership check missing
mode: taint
options:
primitive-tracking: true
pattern-sources:
- patterns:
- pattern: |
@$MAPPING(...)
$METHOD(..., @$INPUT(...) $TYPE $UNTRUSTED, ...) { ... }
- metavariable-pattern:
metavariable: $MAPPING
pattern-either:
- pattern: RequestMapping
- pattern: GetMapping
- pattern: PostMapping
- pattern: PutMapping
- pattern: PatchMapping
- pattern: DeleteMapping
- metavariable-pattern:
metavariable: $INPUT
pattern-either:
- pattern: PathVariable
- pattern: RequestParam
- pattern: RequestBody
- pattern: RequestHeader
- pattern: CookieValue
- pattern: ModelAttribute
- pattern-not-inside: |
@PreAuthorize("@noteAuthorization.isOwner(#id, authentication)")
$METHOD(..., @P("id") @$INPUT(...) $TYPE $UNTRUSTED, ...) {
...
}
- pattern-not-inside: |
@PreAuthorize("isAuthenticated() and @noteAuthorization.isOwner(#id, authentication)")
$METHOD(..., @P("id") @$INPUT(...) $TYPE $UNTRUSTED, ...) {
...
}
- focus-metavariable: $UNTRUSTED
pattern-sinks:
- patterns:
- pattern-either:
- pattern: $SERVICE.readById($UNTRUSTED)
- pattern: $SERVICE.deleteById($UNTRUSTED)
- focus-metavariable: $UNTRUSTED
- id: spring.untrusted-note-owner
languages: [java]
severity: WARNING
message: Raw note access lacks an approved identity binding for this owner; review runtime ownership checks
metadata:
category: authorization-standard
license: MIT
short-description: Note owner is request-controlled
mode: taint
options:
primitive-tracking: true
pattern-sources:
- patterns:
- pattern: |
@$MAPPING(...)
$METHOD(..., @$INPUT(...) $TYPE $UNTRUSTED, ...) {
...
}
- metavariable-pattern:
metavariable: $MAPPING
pattern-either:
- pattern: RequestMapping
- pattern: GetMapping
- pattern: PostMapping
- pattern: PutMapping
- pattern: PatchMapping
- pattern: DeleteMapping
- metavariable-pattern:
metavariable: $INPUT
pattern-either:
- pattern: PathVariable
- pattern: RequestParam
- pattern: RequestBody
- pattern: RequestHeader
- pattern: CookieValue
- pattern: ModelAttribute
- pattern-not-inside: |
@PreAuthorize("#ownerEmail == authentication.name")
$METHOD(..., @P("ownerEmail") @$INPUT(...) $TYPE $UNTRUSTED, ...) {
...
}
- pattern-not-inside: |
@PreAuthorize("isAuthenticated() and #ownerEmail == authentication.name")
$METHOD(..., @P("ownerEmail") @$INPUT(...) $TYPE $UNTRUSTED, ...) {
...
}
- focus-metavariable: $UNTRUSTED
pattern-sinks:
- patterns:
- pattern-either:
- pattern: $SERVICE.listByOwner($UNTRUSTED)
- pattern: $SERVICE.readOwnedById($ID, $UNTRUSTED)
- pattern: $SERVICE.deleteOwnedById($ID, $UNTRUSTED)
- focus-metavariable: $UNTRUSTED
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ repositories {
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation(platform("org.junit:junit-bom:5.10.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/org/seqra/spring/auth/AccessControlDemoData.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.seqra.spring.auth;

import org.seqra.spring.persistence.UserNote;
import org.seqra.spring.persistence.UserNoteRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AccessControlDemoData {

@Bean
CommandLineRunner seedAccessControlNotes(UserNoteRepository notes) {
return args -> {
if (notes.count() == 0) {
notes.save(new UserNote("Alice's note", "Alice's private content", "alice@example.test"));
notes.save(new UserNote("Bob's note", "Bob's private content", "bob@example.test"));
}
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package org.seqra.spring.auth;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class AccessControlSecurityConfig {

@Bean
SecurityFilterChain accessControlSecurity(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/access-control/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.build();
}

@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
UserDetailsService demoUsers(PasswordEncoder encoder) {
return new InMemoryUserDetailsManager(
User.withUsername("alice@example.test")
.password(encoder.encode("alice-demo"))
.roles("USER").build(),
User.withUsername("bob@example.test")
.password(encoder.encode("bob-demo"))
.roles("USER").build());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package org.seqra.spring.auth;

import java.util.List;
import org.seqra.spring.persistence.UserNote;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.parameters.P;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.bind.annotation.*;

// Safe at runtime, but these handlers deliberately violate the annotation standard.
@RestController
@RequestMapping("/api/access-control/nonconforming/notes")
public class NonconformingNoteController {
private final NoteAccessService notes;
private final NoteAuthorization authorization;

public NonconformingNoteController(NoteAccessService notes, NoteAuthorization authorization) {
this.notes = notes;
this.authorization = authorization;
}

@GetMapping("/{id}")
@PreAuthorize("isAuthenticated()")
public UserNote read(@PathVariable("id") long id, Authentication authentication) {
if (!authorization.isOwner(id, authentication)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
}
return notes.readById(id);
}

@GetMapping
@PreAuthorize("#ownerEmail == authentication.name")
public List<UserNote> list(@P("ownerEmail") @RequestParam("ownerEmail") String ownerEmail) {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return notes.listByOwner(ownerEmail);
}
}
44 changes: 44 additions & 0 deletions src/main/java/org/seqra/spring/auth/NoteAccessService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package org.seqra.spring.auth;

import java.util.List;
import org.seqra.spring.persistence.UserNote;
import org.seqra.spring.persistence.UserNoteRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

@Service
public class NoteAccessService {
private final UserNoteRepository notes;

public NoteAccessService(UserNoteRepository notes) {
this.notes = notes;
}

public UserNote readById(long id) {
return notes.findById(id).orElseThrow(NoteAccessService::notFound);
}

@Transactional
public void deleteById(long id) {
notes.delete(readById(id));
}

public List<UserNote> listByOwner(String ownerEmail) {
return notes.findByOwnerEmail(ownerEmail);
}

public UserNote readOwnedById(long id, String ownerEmail) {
return notes.findByIdAndOwnerEmail(id, ownerEmail).orElseThrow(NoteAccessService::notFound);
}

@Transactional
public void deleteOwnedById(long id, String ownerEmail) {
notes.delete(readOwnedById(id, ownerEmail));
}

private static ResponseStatusException notFound() {
return new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}
21 changes: 21 additions & 0 deletions src/main/java/org/seqra/spring/auth/NoteAuthorization.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.seqra.spring.auth;

import org.seqra.spring.persistence.UserNoteRepository;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

@Component("noteAuthorization")
public class NoteAuthorization {
private final UserNoteRepository notes;

public NoteAuthorization(UserNoteRepository notes) {
this.notes = notes;
}

public boolean isOwner(long id, Authentication authentication) {
return authentication != null && authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken)
&& notes.existsByIdAndOwnerEmail(id, authentication.getName());
}
}
56 changes: 56 additions & 0 deletions src/main/java/org/seqra/spring/auth/NoteGateway.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package org.seqra.spring.auth;

import java.util.List;
import org.seqra.spring.persistence.UserNote;
import org.seqra.spring.persistence.UserNoteRepository;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

// Reviewed contract: authorize the final selector and return immutable data.
@Service
public class NoteGateway {
private final UserNoteRepository notes;

public NoteGateway(UserNoteRepository notes) {
this.notes = notes;
}

public record NoteView(long id, String subject, String body) {
static NoteView from(UserNote note) {
return new NoteView(note.getId(), note.getSubject(), note.getBody());
}
}

public NoteView read(long id) {
return notes.findByIdAndOwnerEmail(id, owner())
.map(NoteView::from).orElseThrow(NoteGateway::notFound);
}

public List<NoteView> list() {
return notes.findByOwnerEmail(owner()).stream().map(NoteView::from).toList();
}

@Transactional
public void delete(long id) {
if (notes.deleteOwned(id, owner()) == 0) {
throw notFound();
}
}

private static String owner() {
var authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| authentication instanceof AnonymousAuthenticationToken) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
}
return authentication.getName();
}

private static ResponseStatusException notFound() {
return new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}
Loading
Loading