Skip to content

Safety: Docker hardening, digest pin, local file access policy (1.2.0) - #9

Merged
micschk merged 6 commits into
mainfrom
safety
Oct 12, 2026
Merged

micschk merged 6 commits into
mainfrom
safety

Conversation

@micschk

@micschk micschk commented Oct 12, 2026

Copy link
Copy Markdown
Member

Fixes #3. Not released until a real Docker run is done.

… access policy

- Docker: run as the PHP process's uid:gid, with --init and a unique --name; remove the
  container with docker rm -f when a render times out, throws or PHP shuts down mid-render (#3).
  Optional WKHTMLTOPDF_DOCKER_NETWORK for --network.
- Default image pinned to the multi-platform index digest sha256:3b95bf58...
- WKHTMLTOPDF_LOCAL_FILE_ACCESS: permissive (default) | allowlist | deny; the strict modes refuse
  --enable-local-file-access (and --allow under deny) and pass --disable-local-file-access.
  New WkhtmltoPdf::allowPath() for repeatable --allow.
- Dockerfile: use a local .deb from build/debs/ when present, verify the .deb sha256 either way.
- Docs: Security section, digest verification, backups/restore/rebuild; CHANGELOG 1.2.0.
The container only had the temp dir, so a folder allowed with --allow outside it was allowed by
wkhtmltopdf but missing in the container, and every file:// URL into it failed with
ProtocolUnknownError. Each --allow directory is now bind-mounted read-only at the same path.

- dockerMountPath() refuses relative paths, . and .. segments, ':' ',' and NUL, anything that is
  not an existing directory, '/', top-level directories, whole home directories and system trees
  (/etc, /proc, /root, ...), for the path as given and for its realpath. Folders in the temp dir
  are not mounted again; a link in the temp dir pointing out of it is refused.
- The temp dir is mounted under both spellings when it is behind a symlink: on macOS tempnam()
  returns /private/var/folders/... while sys_get_temp_dir() is /var/folders/..., and the input
  was not found in the container ("Host private not found").
- Tests for the mount list, the refusals and the command that actually runs.

Pair-programmed with my buddy Claude
…r behaviour

Results of renders on the bundled macOS binary, the Docker image (arm64 and amd64) and the bundled
Linux binary on stock Ubuntu 24.04: generic serif/monospace and font aliases resolve to the default
sans font on Linux, WOFF2 webfonts are never applied, webfonts render nondeterministically on the
macOS binary regardless of --javascript-delay, local file access per mode and under Docker,
timeouts leave no container, --user ownership, Docker versus the linux baselines, and how to verify
a restored image backup. README and HOW-IT-WORKS link to it; CHANGELOG 1.2.0 updated.

Pair-programmed with my buddy Claude
The container can only write to the temp dir, so generateFromHtml() with an output path elsewhere
failed with "Unable to write to destination". runDocker() now gives the container a temp path with
the same extension and moves the file to the requested path afterwards (rename, falling back to
copy and unlink across file systems).

- The render's exit code and error output are kept; whatever it wrote is moved also on a non-zero
  exit, as the binary leaves it. A successful render whose file cannot be moved reports exit 1.
- The temp file and its tempnam() reservation are removed after the move, on failure and on timeout.
- Only absolute paths outside the temp dir are redirected; `-`, options and relative paths in raw
  run() calls pass unchanged.
- Tests with a fake docker that writes its last argument; checked with a real render in OrbStack.

Pair-programmed with my buddy Claude
Pair-programmed with my buddy Claude
@micschk
micschk merged commit 9e5c262 into main Oct 12, 2026
4 checks passed
@micschk
micschk deleted the safety branch October 12, 2026 15:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docker strategy: root-owned output in /tmp and container survives a timeout

1 participant