From ff45e54c07d884678fd332a4b410700d1b212f28 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Wed, 17 Jun 2026 15:13:19 -0700 Subject: [PATCH] Run Docker images as non-root user and default to /data workdir Harden the Docker images by dropping root privileges, and make the bind-mount workflow less fiddly. - Create a non-root `app` user (uid/gid 1000) in both images and add `USER app` before the entrypoint, so ocrmypdf (and the webservice/watcher) no longer run as root. This also fixes the previously dangling `--chown=app:app`, which referenced a user that was never created. The Ubuntu base ships a default `ubuntu`/1000 user, so remove it first so `app` can take uid 1000 (parity with Alpine). - Add `WORKDIR /data` (created and app-owned) so bind-mounted input and output can be passed as relative paths without `--workdir`. The webservice/watcher are now invoked by absolute path (`/app/*.py`) since the working directory is no longer `/app`. - Drop the redundant `ppa:alex-p/tesseract-ocr5` from the Ubuntu image: Tesseract 5 ships in the Ubuntu archive as of 24.04, and the PPA had no build for the 26.04 base, which broke the build outright. - Rewrite docs/docker.md rootless-first: stdin/stdout piping as the recommended permission-free path, then per-runtime volume guidance (rootless Docker `--user 0:0`, Podman `--userns keep-id`, rootful Docker as the special case). Update batch.md and the compose example to match (absolute script paths, per-runtime `user:` guidance). --- .docker/Dockerfile | 32 ++++++++-- .docker/Dockerfile.alpine | 23 ++++++- docs/batch.md | 10 ++- docs/docker.md | 107 +++++++++++++++++++++++++------- misc/docker-compose.example.yml | 11 +++- 5 files changed, 151 insertions(+), 32 deletions(-) diff --git a/.docker/Dockerfile b/.docker/Dockerfile index bda272d2..e9f8bf16 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -60,10 +60,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \ FROM base -RUN apt-get update && apt-get install -y software-properties-common - -RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5 - +# Tesseract 5 ships in the Ubuntu archive as of 24.04, so no third-party PPA is +# needed. (Previously this used ppa:alex-p/tesseract-ocr5.) RUN apt-get update && apt-get install -y --no-install-recommends \ ghostscript \ fonts-droid-fallback \ @@ -81,6 +79,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ unpaper \ && rm -rf /var/lib/apt/lists/* +# Create a non-root user to run the application (defense in depth). The build +# stages above need root to install packages, but the entrypoint should not. +# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable +# and matches the --chown below. See docs/docker.md for the volume/permissions +# implications under rootless vs rootful Docker. +# The Ubuntu base ships a default "ubuntu" user at uid/gid 1000; remove it so +# "app" can claim that uid for parity with the Alpine image. +RUN userdel -r ubuntu 2>/dev/null; groupdel ubuntu 2>/dev/null; \ + groupadd -g 1000 app \ + && useradd -u 1000 -g app -m -d /home/app app +ENV HOME=/home/app + WORKDIR /app COPY --from=builder /usr/local/lib/ /usr/local/lib/ @@ -90,9 +100,21 @@ COPY --from=builder --chown=app:app /app /app RUN rm -rf /app/.git && \ ln -s /app/misc/webservice.py /app/webservice.py && \ -ln -s /app/misc/watcher.py /app/watcher.py +ln -s /app/misc/watcher.py /app/watcher.py && \ +chown app:app /app + +# Default working directory for bind-mounted data, so relative input/output +# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`). +# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this. +RUN mkdir -p /data && chown app:app /data +WORKDIR /data ENV PATH="/app/.venv/bin:${PATH}" +# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when +# overridden) as the unprivileged app user. Override with `--user root` if you +# need root inside a running container (e.g. to apt install extra packages). +USER app + ENTRYPOINT ["/app/.venv/bin/ocrmypdf"] diff --git a/.docker/Dockerfile.alpine b/.docker/Dockerfile.alpine index 1b2630d6..0ec390a0 100644 --- a/.docker/Dockerfile.alpine +++ b/.docker/Dockerfile.alpine @@ -62,14 +62,35 @@ RUN apk add --no-cache \ unpaper \ && rm -rf /var/cache/apk/* +# Create a non-root user to run the application (defense in depth). The build +# stages above need root to install packages, but the entrypoint should not. +# A fixed uid/gid of 1000 keeps `--user`/`--userns keep-id` mappings predictable +# and matches the --chown below. See docs/docker.md for the volume/permissions +# implications under rootless vs rootful Docker. +RUN addgroup -g 1000 app \ + && adduser -u 1000 -G app -D -h /home/app app +ENV HOME=/home/app + WORKDIR /app COPY --from=builder --chown=app:app /app /app RUN rm -rf /app/.git && \ ln -s /app/misc/webservice.py /app/webservice.py && \ - ln -s /app/misc/watcher.py /app/watcher.py + ln -s /app/misc/watcher.py /app/watcher.py && \ + chown app:app /app + +# Default working directory for bind-mounted data, so relative input/output +# paths work without passing --workdir (e.g. `-v "$PWD:/data" in.pdf out.pdf`). +# The webservice/watcher are run by absolute path (/app/*.py), unaffected by this. +RUN mkdir -p /data && chown app:app /data +WORKDIR /data ENV PATH="/app/.venv/bin:${PATH}" +# Drop privileges: run the entrypoint (ocrmypdf, or the webservice/watcher when +# overridden) as the unprivileged app user. Override with `--user root` if you +# need root inside a running container (e.g. to apk add extra packages). +USER app + ENTRYPOINT ["/app/.venv/bin/ocrmypdf"] diff --git a/docs/batch.md b/docs/batch.md index 53bc6f15..43651f8f 100644 --- a/docs/batch.md +++ b/docs/batch.md @@ -174,7 +174,15 @@ docker run \ --env PYTHONUNBUFFERED=1 \ --interactive --tty --entrypoint python3 \ jbarlow83/ocrmypdf \ - watcher.py + /app/watcher.py +::: + +:::{note} +The image runs as the non-root `app` user (uid 1000) by default, so it +may not be able to write to the `/output` and `/processed` volumes unless +you add a `--user` argument. The correct value depends on whether you use +rootful Docker, rootless Docker, or Podman -- see +{ref}`Bind-mounted volumes ` for details. ::: This service will watch for a file that matches `/input/\*.pdf`, convert diff --git a/docs/docker.md b/docs/docker.md index 932a6c5a..e3f24ec4 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -71,15 +71,29 @@ application (as opposed to the more conventional case, where a Docker container runs as a server). For that reason we usually use the `--rm` argument to delete the container when it exits. +:::{note} +The image runs as a non-root user (`app`, uid/gid 1000) by default, +rather than as root. This is a defense-in-depth measure: a flaw in +OCRmyPDF or one of its dependencies cannot trivially act as root inside +the container. The examples below assume **rootless Docker** or +**Podman**; the differences for traditional *rootful* Docker are +described separately under *Special case: rootful Docker* below. +::: + To start a Docker container (instance of the image): :::{code} bash docker run --rm -i jbarlow83/ocrmypdf-alpine (... all other arguments here...) - - ::: -For convenience, create a shell alias to hide the Docker command. It is -easier to send the input file as stdin and read the output from stdout --- **this avoids the messy permission issues with Docker entirely**. +### Recommended: pipe through stdin and stdout + +The easiest and most portable way to use the image is to send the input +file on stdin and read the output from stdout. This **avoids file +permission issues entirely** -- nothing is written to a mounted +directory, so it does not matter which user the container runs as, nor +whether you use rootless or rootful Docker. For convenience, create a +shell alias to hide the Docker command: :::{code} bash alias docker_ocrmypdf='docker run --rm -i jbarlow83/ocrmypdf-alpine' @@ -90,28 +104,42 @@ docker_ocrmypdf - - output.pdf Or in the wonderful [fish shell](https://fishshell.com/): :::{code} fish -alias docker_ocrmypdf 'docker run --rm jbarlow83/ocrmypdf-alpine' +alias docker_ocrmypdf 'docker run --rm -i jbarlow83/ocrmypdf-alpine' funcsave docker_ocrmypdf ::: -Alternately, you could mount the local current working directory as a -Docker volume: +{#docker-volumes} +### Bind-mounted volumes + +If you would rather mount a directory and pass file paths, you need to +consider which user owns the files OCRmyPDF writes back into that +directory. The image's default working directory is `/data`, so mounting +your files there lets you pass plain relative paths without an explicit +`--workdir`. Because the container runs as the non-root `app` user, the +right invocation otherwise depends on your container runtime. + +**Rootless Docker (the assumed default).** Your own account runs the +daemon, so the container's `root` maps back to *your* unprivileged host +user, while every other container uid -- including the image's default +`app`/1000 -- maps to a *subordinate* uid. A directory you own on the +host therefore appears owned by `root` inside the container, so the +default `app` user usually **cannot write to it at all**. Run the job as +container-`root`, which under rootless Docker is still your ordinary host +user, so the write succeeds and the output is owned by you: :::{code} bash -alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine' -docker_ocrmypdf /data/input.pdf /data/output.pdf +alias docker_ocrmypdf='docker run --rm -i --user 0:0 -v "$PWD:/data" jbarlow83/ocrmypdf-alpine' +docker_ocrmypdf input.pdf output.pdf ::: -## Podman - -Especially if you use [Podman](https://podman.io/) (or use Docker in -rootless mode), you may need to add `--userns keep-id` there, -otherwise you may get access errors, because the user ID is otherwise not -mapped to the same UID as on the host: +**Podman.** Podman provides `--userns keep-id`, which maps your host uid +straight through into the container. Combined with `--user`, you run as +your own uid and own the output directly, otherwise you may get access +errors because the user ID is not mapped to the same UID as on the host: :::{code} bash -alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" jbarlow83/ocrmypdf-alpine' -podman_ocrmypdf /data/input.pdf /data/output.pdf +alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" jbarlow83/ocrmypdf-alpine' +podman_ocrmypdf input.pdf output.pdf ::: If you have SELinux enabled, you may additionally need to add the `:Z` [suffix to @@ -124,10 +152,27 @@ the end of the linked podman documentation for details. This results in the following full command: :::{code} bash -alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id --workdir /data -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine' -podman_ocrmypdf /data/input.pdf /data/output.pdf +alias podman_ocrmypdf='podman run --rm -i --user "$(id -u):$(id -g)" --userns keep-id -v "$PWD:/data" --security-opt label=disable jbarlow83/ocrmypdf-alpine' +podman_ocrmypdf input.pdf output.pdf ::: +{#docker-rootful} +### Special case: rootful Docker + +With a traditional root daemon, container uid *N* is the *same* uid *N* +on the host. Running the container as root would therefore fill your +mounted directory with root-owned files and -- more importantly -- a +container escape would run as real host root. Drop to your own uid so the +output is owned by you and the process stays unprivileged: + +:::{code} bash +alias docker_ocrmypdf='docker run --rm -i --user "$(id -u):$(id -g)" -v "$PWD:/data" jbarlow83/ocrmypdf-alpine' +docker_ocrmypdf input.pdf output.pdf +::: + +The non-root default and the `--user` override both reduce the risk here, +but rootless Docker or Podman remain the safer choice when available. + {#docker-lang-packs} ## Adding languages to the Docker image @@ -139,8 +184,12 @@ creating a new Dockerfile based on the public one. :::{code} dockerfile FROM jbarlow83/ocrmypdf +# The image runs as the non-root "app" user, so switch back to root for +# build steps that install packages, then drop back to "app". +USER root # Example: add Italian -RUN apt install tesseract-ocr-ita +RUN apt-get update && apt-get install -y tesseract-ocr-ita +USER app ::: To install language packs (training data) such as the @@ -179,7 +228,11 @@ Extending the Docker image -------------------------- You can extend the Docker image with your own customizations, similar to -the way it is extended to add language packs. +the way it is extended to add language packs. Because the image runs as +the non-root `app` user, switch to `USER root` for any build steps that +require root (installing packages, writing to system directories) and +back to `USER app` afterwards, as shown in the language pack example +above. Note that the Docker image is subject to change at any time. For example, the base image may be updated to a newer version of Ubuntu or @@ -196,7 +249,7 @@ Executing the test suite The OCRmyPDF test suite is installed with image. To run it: :::{code} bash -docker run --rm --entrypoint python jbarlow83/ocrmypdf -m pytest +docker run --rm --workdir /app --entrypoint python jbarlow83/ocrmypdf -m pytest ::: Accessing the shell @@ -205,7 +258,15 @@ Accessing the shell To use the shell in the Docker image: :::{code} bash -docker run -it --entrypoint sh jbarlow83/ocrmypdf +docker run -it --entrypoint sh jbarlow83/ocrmypdf-alpine +::: + +This shell runs as the non-root `app` user. If you need root inside the +container -- for example to install extra packages with `apk` or `apt` -- +add `--user root`: + +:::{code} bash +docker run -it --user root --entrypoint sh jbarlow83/ocrmypdf-alpine ::: Using the OCRmyPDF web service wrapper @@ -215,7 +276,7 @@ The OCRmyPDF Docker image includes an example, barebones HTTP web service. The webservice may be launched as follows: :::{code} bash -docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf webservice.py +docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf /app/webservice.py ::: We omit the `--rm` parameter so that the container will not be diff --git a/misc/docker-compose.example.yml b/misc/docker-compose.example.yml index c8c77b36..70df95c7 100644 --- a/misc/docker-compose.example.yml +++ b/misc/docker-compose.example.yml @@ -6,12 +6,19 @@ services: ocrmypdf: restart: always container_name: ocrmypdf - image: jbarlow83/ocrmypdf + image: jbarlow83/ocrmypdf-alpine volumes: - "/media/scan:/input" - "/mnt/scan:/output" environment: - OCR_OUTPUT_DIRECTORY_YEAR_MONTH=0 + # The image runs as the non-root "app" user (uid 1000) by default. The + # correct value here depends on your runtime, so that the watcher can write + # to the /output bind mount and the files end up owned by you: + # rootful Docker -> your host uid:gid + # rootless Docker -> "0:0" (container root maps to your host user) + # Podman -> your host uid:gid, plus `userns_mode: "keep-id"` + # See docs/docker.md ("Bind-mounted volumes") for the reasoning. user: ":" entrypoint: python3 - command: watcher.py + command: /app/watcher.py