# 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":""}` 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 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 searchByTitle(@Param("query") String query); // GOOD: For dynamic filtering, use JPA Criteria API or Specifications public List searchDocuments(String query, LocalDate from, LocalDate to) { return documentRepository.findAll((root, cq, cb) -> { List 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
{@html document.description}
``` **Attack:** A user stores `` 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
{document.description}
{@html safeHtml}
``` **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 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 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", "".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 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 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 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 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; 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+*