Attachment Security: Scanning, MIME Allowlist & Transforms
July 29, 2026 · View on GitHub
rela inspects every uploaded attachment on the write path before it is stored. Two layers do the work:
- Native input validation (always on, pure Go, no external tools) — a sniffed MIME allowlist and download hardening. This is your out-of-box defense against stored-XSS and disallowed file types.
- External-command processing (opt-in) — virus scanning and byte transforms (metadata strip, resize, CDR) are driven by commands you configure. rela ships no scanner or image library; it runs the tools you already trust (ClamAV, vips, exiftool, qpdf, …) with safe invocation.
This split mirrors how rela handles authentication: integration is pushed to a well-understood external boundary, keeping rela's core small. You bring the binary; rela drives it safely and records the policy declaratively in your metamodel.
Running a third-party parser on untrusted input is a real attack surface. Pin tool versions and keep them patched. ImageMagick in particular has a history of parser CVEs — see the recipes below for
-limithardening.
rela confines every scan/transform command it runs, rather than leaving isolation entirely to you. Each command gets:
- no network access (bubblewrap on Linux,
sandbox-execon macOS), which removes the server-side request forgery vector — a crafted upload cannot make the server fetch internal services or cloud metadata; - a read-only view of the system, with only the command's own temp directory writable;
- resource ceilings on memory, processes, file size, and CPU (Linux), so a malicious file cannot exhaust the host;
- process-group cleanup, so a helper spawned by the tool cannot outlive the timeout;
- a bounded pool, so concurrent uploads cannot multiply resource use.
Where no sandbox mechanism is available (Windows, BSD, or a Linux kernel without unprivileged user namespaces), commands refuse to run rather than run unconfined — the same fail-closed stance as a scanner that cannot start. Only command execution is blocked; the server still starts. The startup log states which mechanism is in use. Deployment-level isolation (a container, a dedicated user, a no-egress network policy) remains worthwhile defence in depth.
Configuration
Attachment policy lives in two places in metamodel.yaml: a global
attachments: block (the safety floor for every file property) and per-property
overrides.
# Global safety floor — applies to every `file` property unless overridden.
attachments:
allow: default-safe # MIME allowlist preset, or an explicit list
scan_cmd: [clamdscan, --no-summary, --fdpass, "{in}"] # configuring this enables scanning
entities:
report:
properties:
# Inherits the global scan command + allowlist.
evidence:
type: file
max: 5
transform:
- cmd: [vips, thumbnail, "{in}", "{out}[Q=85,strip]", "2048"]
# A signed PDF: scanned (inherits the global command), kept byte-for-byte
# (no transform), and only PDFs are accepted on this field.
signed_contract:
type: file
accept: [application/pdf]
# An internally-generated, already-trusted export: opt out of scanning.
nightly_export:
type: file
scan: off
MIME allowlist (allow, accept)
The allowlist is checked against the sniffed content type
(http.DetectContentType on the file's magic bytes), never the client-supplied
Content-Type header — which is trivially spoofed. A file whose extension
implies one type but whose bytes sniff as an incompatible one (the classic
.jpg.php / SVG-polyglot trick) is rejected.
allow: default-safe(the default when unset) permits common images (png,jpeg,gif,webp),pdf, plain text, CSV, ZIP, and the genericapplication/octet-stream(office documents sniff this way). It blocksimage/svg+xml,text/html,xhtml, JavaScript, and executable extensions (.exe,.dll,.sh,.ps1, …) — the active/script-carrying types that drive stored-XSS and code execution.allow: [image/png, application/pdf]— an explicit list narrows the global floor.- Per-property
accept: [application/pdf]narrows a single field further.
Scanning (scan_cmd, scan: off)
Configuring a scan_cmd is what turns scanning on — there is no separate
"required" toggle. If a scan command is configured for a file property
(globally via attachments.scan_cmd, or on the property itself), every upload
to it is scanned, fail-closed: the upload is rejected if the scanner reports
a hit or if the scanner cannot run (missing binary, timeout, daemon down).
| config | behavior |
|---|---|
scan_cmd set (global or per-property) | Scan every upload, fail-closed. |
no scan_cmd anywhere | No scanning. rela logs a one-time startup warning so the omission is a conscious choice, not an accident. |
property scan: off | Skip scanning on that one property, even when a global scan_cmd exists. Silences the warning for it. |
A property's own scan_cmd overrides the global one. To silence the startup
warning without scanning, either set a global scan_cmd or mark each file
property scan: off.
A scan command is a verdict gate: rela ignores its output and reads only its
exit code — 0 means clean, any non-zero means "not clean" and the upload
is rejected. scan_cmd is an array of arguments — never a shell string, so a
filename or byte sequence can never inject a shell command. Use the {in}
placeholder for the file; rela substitutes a path to a temp file it owns.
Transforms (transform)
transform is an ordered list of steps that rewrite the bytes. Each step is
one of two kinds — set exactly one per step:
{cmd: [...]}— an external command. Use{in}for the input file and{out}for the output file rela should read back; if a command writes to stdout instead, omit{out}. External commands are sandboxed (see Safe-invocation guarantees).{image: {...}}— a native, in-process image transform. No external tool, no sandbox needed: rela decodes the upload with a memory-safe pure-Go decoder, bakes in EXIF orientation, and re-encodes to a canonical format.
Transforms are opt-in per field — they mutate bytes, so rela never applies one unless you ask.
(Note the asymmetry with scanning: a scan is a gate judged by exit code; a transform is a filter whose output replaces the stream.)
Native image transform (image)
The image step handles the common raster formats with no external dependency:
avatar:
type: file
transform:
- image: { reencode: jpeg, quality: 85 } # strip metadata, orient, normalize
- Inputs: PNG, JPEG, GIF, and WebP (decode). Output:
reencode: jpeg(the default) orpng— there is no pure-Go WebP encoder, so a WebP upload is re-encoded to JPEG/PNG.qualitysets JPEG quality (ignored for PNG). - EXIF orientation is always applied — a phone photo tagged "rotate 90°" is stored visually upright. There is no opt-out: storing sideways would be worse than not processing.
- Metadata is stripped as a side effect of re-encoding: EXIF, GPS, and any trailing data are gone from the stored bytes.
- The stored file extension is updated to match the output format (
.jpg/.png), so the downloadContent-Typestays consistent with the bytes. - Why it needs no sandbox: the Go decoders are memory-safe, so a crafted
image cannot achieve code execution — the residual risk is denial-of-service
only, which rela bounds with a pixel cap (rejects decompression bombs from the
header, before allocating), a decode timeout, and a process-wide concurrency
limit. Keep rela updated: the underlying
golang.org/x/imagedecoders receive periodic security fixes for malformed-input crashes (rela runsgovulncheckin CI to catch them). - Not handled: animated (multi-frame) GIF is rejected rather than
silently flattened to one frame — use a
cmd:transform if you need to process it. HEIC/AVIF/JPEG-XL/RAW and SVG are not supported by the native step; use acmd:transform (e.g. vips) for those. - Available everywhere, including
rela attachon the CLI and on platforms where the external-command sandbox is unavailable — because it runs in-process.
Safe-invocation guarantees
These apply to external scan and cmd: transform commands (the native
image: transform runs in-process and needs none of them). For every such
command, rela:
- runs the binary directly with array args — no shell, so no injection;
- substitutes
{in}/{out}with temp-file paths it owns — your command never sees, and never has to sanitize, the user's filename; - enforces a timeout (60s per command) and an output-size cap (the per-attachment limit, 64 MiB);
- probes every configured binary at startup and warns if one is missing, so you find a typo or an uninstalled tool at boot, not on first upload.
Recipes
These are starting points. Test them against your own deployment, pin versions, and sandbox the tools.
Virus scanning — ClamAV
Run the ClamAV daemon (clamd) and scan over its stream interface:
attachments:
scan_cmd: [clamdscan, --no-summary, --fdpass, "{in}"]
Configuring the command enables scanning for every file property (those that
don't opt out with scan: off). clamdscan exits non-zero when a signature
matches, which rela maps to a rejected upload. --fdpass hands the file
descriptor to the daemon (fast, no copy); use --stream instead if clamd
runs on another host.
On Windows, point scan_cmd at your AV's CLI scanner (e.g. a Microsoft
Defender MpCmdRun.exe -Scan -ScanType 3 -File {in} invocation that exits
non-zero on detection).
Reaching clamd from inside the sandbox
clamdscan talks to the daemon over a unix socket, but the sandbox gives
each command its own mount namespace — the socket's path is not visible unless
rela binds it in. rela already binds the well-known locations read-only:
/var/run/clamav/clamd.ctl
/run/clamav/clamd.sock
/var/run/clamav/clamd.sock
/tmp/clamd.socket
so a stock ClamAV install (Debian/Ubuntu clamav-daemon, Homebrew clamav)
works with no extra configuration. Binding the socket does not re-open
network egress — a unix socket is a filesystem object, and the network namespace
stays isolated, so --fdpass/LocalSocket scanning works while an outbound
fetch still cannot.
If your clamd.conf uses a LocalSocket outside those paths, bind it explicitly:
attachments:
scan_cmd: [clamdscan, --no-summary, --fdpass, "{in}"]
scan_sockets: [/opt/clamav/run/clamd.sock] # extra read-only binds
The --stream / TCP transport needs no socket bind, but it does need
network egress, which the sandbox denies — prefer the local socket. Scan over
TCP only when clamd runs on another host, and provide egress at the
deployment layer (the sandbox has no per-command egress opt-in).
Strip image metadata (EXIF/GPS) — exiftool
evidence_photo:
type: file
transform:
- cmd: [exiftool, -all=, -overwrite_original, "{in}", -o, "{out}"]
exiftool -all= removes every metadata block (EXIF, GPS, XMP, IPTC) losslessly
— it does not re-encode the pixels.
Strip metadata + resize — vips
avatar:
type: file
transform:
- cmd: [vips, thumbnail, "{in}", "{out}[Q=85,strip]", "512"]
vips thumbnail resizes to fit 512px; the [strip] save option drops metadata
and [Q=85] sets JPEG quality. vips handles HEIC/TIFF/RAW that the native
sniffer cannot.
Resize / convert — ImageMagick (with hardening)
photo:
type: file
transform:
- cmd: [magick, "{in}", -strip, -resize, "2048x2048>", -limit, memory, "256MiB", -limit, disk, "1GiB", "{out}"]
-strip removes metadata; -resize 2048x2048> only shrinks (never upscales);
the -limit flags cap ImageMagick's resource use — important given its parser
CVE history. Prefer vips or exiftool where they suffice.
Document disarm (CDR) — qpdf / Ghostscript
Re-write a PDF to neutralize embedded JavaScript and malformed structures:
contract:
type: file
accept: [application/pdf]
transform:
- cmd: [qpdf, --linearize, --object-streams=generate, "{in}", "{out}"]
For heavier sanitization, flatten through Ghostscript:
contract:
type: file
accept: [application/pdf]
transform:
- cmd: [gs, -q, -dNOPAUSE, -dBATCH, -dSAFER, -sDEVICE=pdfwrite, -sOutputFile={out}, "{in}"]
Notes & limits
- Existing files are not retroactively processed. Scanning, the allowlist, and transforms are write-time gates. Tightening policy does not re-scan or re-strip attachments already stored. (A bulk rescan tool may come later.)
- Synchronous. Processing runs during the upload request, so the browser's
progress bar covers it. Keep
scan_cmd/transforms fast for large files. - Scanning is fail-closed. Once a
scan_cmdis configured, uploads to that property stop working when the scanner is down — that is the intended safety behavior. Setscan: offon a property where availability matters more than the guarantee.