From e99f8b0bc0330c760d19578b81c18c8d50d08566 Mon Sep 17 00:00:00 2001 From: YoursFunny Date: Thu, 24 Sep 2026 03:43:04 +0800 Subject: [PATCH] build: pin ffmpeg to a versioned URL and verify its sha256 unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default build fetched /redirect/latest/ — a floating URL that changes under every build — and skipped the sha256 check whenever FFMPEG_SHA256 was empty, which it always was: neither the Dockerfile nor docker.yml carried a hash, so any binary the CDN served reached the image unverified and then processed untrusted media bytes. Both now pin the 9.0.2 release build with the hash the mirror publishes beside it (verified here by direct digest of the downloaded zip: fa8ecf4a…909d7f matches the sidecar), docker.yml's build-args fall back to the same pair, and the check is unconditional — an FFMPEG_URL override without its matching hash fails the build at the download step. The ffmpeg layer also moves above the dependency layer so a manifest/lock edit no longer re-downloads it (the old comment claimed caching 'unless FFMPEG_URL changes', which was never true), and the stale step reference in the sources comment goes with it. Local note: Git-Bash's sha256sum -c reads files in text mode here and cannot roundtrip anything binary — the -c line itself is standard and is exercised by the PR build in CI. --- .github/workflows/docker.yml | 12 +++++---- Dockerfile | 52 +++++++++++++++++++----------------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4d41e1a..d6e5e67 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -150,9 +150,11 @@ jobs: # would give every new tag a cold cache on release builds. PR runs only # read it (cache-to is empty) so they cannot evict the release cache. # - # FFMPEG_URL/FFMPEG_SHA256 come from repository variables when set, so a - # release can pin an exact ffmpeg build (the Dockerfile default follows - # the project's `/redirect/latest/` URL, which has no sha256 sidecar). + # FFMPEG_URL/FFMPEG_SHA256 come from repository variables when set — + # both or neither: the Dockerfile checks the sha256 unconditionally, so + # a URL without its matching hash fails the build. The fallbacks pin the + # same 9.0.2 release the Dockerfile defaults to (keep the three in step + # when bumping). # # Single-arch (amd64) on purpose: adding arm64 means re-adding # `docker/setup-qemu-action`, `platforms: linux/amd64,linux/arm64`, and @@ -164,8 +166,8 @@ jobs: push: ${{ github.event_name != 'pull_request' }} build-args: | APP_NAME=${{ env.APP_NAME }} - FFMPEG_URL=${{ vars.FFMPEG_URL || 'https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip' }} - FFMPEG_SHA256=${{ vars.FFMPEG_SHA256 }} + FFMPEG_URL=${{ vars.FFMPEG_URL || 'https://ffmpeg.martin-riedl.de/download/linux/amd64/1789931100_9.0.2/ffmpeg.zip' }} + FFMPEG_SHA256=${{ vars.FFMPEG_SHA256 || 'fa8ecf4abbd290d98f7d188b8649cc6b391ae209a98452be955a15aab1909d7f' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha,scope=tgxmb-build diff --git a/Dockerfile b/Dockerfile index 650c0c9..d524eea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,20 +8,36 @@ ARG APP_NAME=telegram-twitter-media-bot # encoding. Served from https://ffmpeg.martin-riedl.de (Cloudflare CDN, # built on Debian 12 — glibc-compatible with the bookworm-slim runtime). # johnvansickle.com throttles datacenter IPs and served garbage from GitHub -# runners. `/redirect/latest/` floats to the newest release build; each build -# also ships a .sha256. Swap `amd64` for `arm64` when building arm64 images. -ARG FFMPEG_URL=https://ffmpeg.martin-riedl.de/redirect/latest/linux/amd64/release/ffmpeg.zip -# Arm64 images need this URL swapped for the `linux/arm64` build (currently -# hardcoded amd64; the workflow builds amd64 only — see docker.yml). -# Optional sha256 of ffmpeg.zip (pinned releases only): set to verify the -# download. The mirror publishes .sha256 sidecars next to pinned builds, e.g. -# https://ffmpeg.martin-riedl.de/download/linux/amd64/_9.0/ffmpeg.zip.sha256 -# (the /redirect/latest/ URL itself has no sidecar — pin the effective URL). -ARG FFMPEG_SHA256= +# runners. +# +# Pinned to one release build instead of `/redirect/latest/`: the floating +# URL changes under every build and ships no sha256 sidecar, while this pair +# (zip + the sha256 the mirror publishes beside it, `.sha256`) is +# verified on every run. Bump both together — the site lists the current +# ids, e.g. https://ffmpeg.martin-riedl.de. Swap `amd64` for `arm64` when +# building arm64 images (the workflow builds amd64 only — see docker.yml). +ARG FFMPEG_URL=https://ffmpeg.martin-riedl.de/download/linux/amd64/1789931100_9.0.2/ffmpeg.zip +# sha256 of that zip, checked unconditionally: an FFMPEG_URL override must +# pair with the new zip's sha256 or the build fails here, so an unverifiable +# binary never reaches the image. +ARG FFMPEG_SHA256=fa8ecf4abbd290d98f7d188b8649cc6b391ae209a98452be955a15aab1909d7f WORKDIR /build -# 1. Rust dependencies first: only the manifests plus stub sources, so the +# 1. Static ffmpeg first: only the two ARGs above invalidate this layer, so a +# manifest or source edit never re-downloads it. The zip contains a single +# `ffmpeg` binary at the root. `unzip -t` verifies the archive before +# extraction so a bad download fails loudly here instead of a cryptic +# later error. +RUN wget -q -O /tmp/ffmpeg.zip "$FFMPEG_URL" \ + && echo "$FFMPEG_SHA256 /tmp/ffmpeg.zip" | sha256sum -c - \ + && unzip -tq /tmp/ffmpeg.zip \ + && unzip -q /tmp/ffmpeg.zip -d /usr/local/bin \ + && chmod +x /usr/local/bin/ffmpeg \ + && rm /tmp/ffmpeg.zip \ + && /usr/local/bin/ffmpeg -version >/dev/null + +# 2. Rust dependencies next: only the manifests plus stub sources, so the # expensive dependency fetch + compile lives in a layer invalidated only by # manifest/lock changes. COPY Cargo.toml Cargo.lock ./ @@ -32,21 +48,9 @@ RUN mkdir -p crates/x-media/src crates/xmedia-bot/src \ && : > crates/x-media/src/lib.rs \ && cargo build --release --locked -p xmedia-bot -# 2. Static ffmpeg next (cached unless FFMPEG_URL changes), so source edits -# never re-download it. The zip contains a single `ffmpeg` binary at the -# root. `unzip -t` verifies the archive before extraction so a bad -# download fails loudly here instead of a cryptic later error. -RUN wget -q -O /tmp/ffmpeg.zip "$FFMPEG_URL" \ - && if [ -n "$FFMPEG_SHA256" ]; then echo "$FFMPEG_SHA256 /tmp/ffmpeg.zip" | sha256sum -c -; fi \ - && unzip -tq /tmp/ffmpeg.zip \ - && unzip -q /tmp/ffmpeg.zip -d /usr/local/bin \ - && chmod +x /usr/local/bin/ffmpeg \ - && rm /tmp/ffmpeg.zip \ - && /usr/local/bin/ffmpeg -version >/dev/null - # 3. Real sources last: only our crates recompile on source changes. Cargo's # freshness check is mtime-based; the COPY'd host files usually predate the -# step-1 stub build, so cargo would consider the stub up to date and never +# stub build, so cargo would consider the stub up to date and never # compile the real sources. `touch` makes every .rs newer than the stub # artifacts, forcing a rebuild of just the two crates while the compiled # dependency layer stays cached. (`cargo clean -p` does NOT work here — it