Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/php-nginx-74-security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Validate php-nginx 7.4 security backport

on:
pull_request:
paths:
- php-nginx/7.4/**
- .github/workflows/php-nginx-74-security.yml
workflow_dispatch:

permissions:
contents: read

jobs:
compatibility:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Build patched image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8
with:
context: ./php-nginx/7.4
target: php-nginx
platforms: linux/amd64
load: true
tags: devitools/php-nginx:7.4
cache-from: type=gha,scope=php-nginx-74-security
cache-to: type=gha,mode=max,scope=php-nginx-74-security
- name: Verify compatibility and security behavior
run: python3 php-nginx/7.4/.scripts/smoke.py devitools/php-nginx:7.4
18 changes: 18 additions & 0 deletions php-nginx/7.4/.scripts/apply-patches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
set -euo pipefail
cd "$1"
mkdir -p /security/patches /security/sury-patches
while read -r name selected_file; do
test -n "$name" || continue
original="/tmp/sury/debian/patches/$name"
cp "$original" "/security/sury-patches/$name"
if [ -n "$selected_file" ]; then
awk -v target="diff --git a/$selected_file b/$selected_file" '
/^diff --git / { keep = ($0 == target) }
keep { print }
' "$original" > "/security/patches/$name"
test -s "/security/patches/$name"
else
cp "$original" "/security/patches/$name"
fi
patch --batch --fuzz=0 -p1 < "/security/patches/$name"
done < /tmp/build/patches.list
53 changes: 53 additions & 0 deletions php-nginx/7.4/.scripts/build-php.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
set -euxo pipefail

php -r '$m = get_loaded_extensions(); sort($m); file_put_contents("/tmp/upstream-modules.json", json_encode($m));'
php-config --configure-options > /tmp/configure-options
. /etc/os-release
case "$VERSION_CODENAME" in
stretch|buster)
find /etc/apt -type f \( -name '*.list' -o -name '*.sources' \) -exec sed -i \
-e 's|http://deb.debian.org/debian|http://archive.debian.org/debian|g' \
-e 's|http://security.debian.org/debian-security|http://archive.debian.org/debian-security|g' {} +
;;
esac
find /etc/apt -type f -name '*.list' -exec sed -i \
-e '/nginx\.org/d' -e '/stretch-updates/d' {} +
apt-get -o Acquire::Check-Valid-Until=false update
build_deps=(libcurl4-openssl-dev libedit-dev libreadline-dev libsqlite3-dev libxml2-dev zlib1g-dev)
case "$EXPECTED_PHP_VERSION" in
5.6.*|7.0.*) build_deps+=(libssl1.0-dev) ;;
*) build_deps+=(libargon2-dev libonig-dev libsodium-dev libssl-dev) ;;
esac
apt-get install -y --no-install-recommends $PHPIZE_DEPS patch "${build_deps[@]}"

mkdir -p /security /tmp/sury /usr/src/php /patched
curl -fsSL --retry 3 "$SURY_SOURCE_URL" -o /tmp/sury.tar.xz
printf '%s %s\n' "$SURY_SOURCE_SHA256" /tmp/sury.tar.xz | sha256sum -c -
tar -xJf /tmp/sury.tar.xz -C /tmp/sury
printf '%s %s\n' "$PHP_SOURCE_SHA256" /usr/src/php.tar.xz | sha256sum -c -
tar -xJf /usr/src/php.tar.xz -C /usr/src/php --strip-components=1
bash /tmp/build/apply-patches.sh /usr/src/php
cp /tmp/sury/debian/changelog /security/sury-changelog
cp /tmp/build/patches.list /security/patches.list
cp /tmp/upstream-modules.json /security/upstream-modules.json
printf '%s\n' "$SURY_SOURCE_URL" > /security/source-url
printf '%s\n' "$SURY_SOURCE_SHA256" > /security/source-sha256
printf '%s\n' "$PHP_SOURCE_SHA256" > /security/upstream-source-sha256
export CFLAGS="$PHP_CFLAGS" CPPFLAGS="$PHP_CPPFLAGS" LDFLAGS="$PHP_LDFLAGS"
read -r -a original_options < /tmp/configure-options
configure_options=()
for option in "${original_options[@]}"; do
case "$option" in --*) configure_options+=("$option") ;; esac
done
cd /usr/src/php
./configure "${configure_options[@]}" --enable-opcache=shared
make -j"$BUILD_JOBS"
make install INSTALL_ROOT=/patched
rm -rf /patched/usr/local/etc /patched/usr/local/php /patched/usr/local/var
cp /tmp/configure-options /security/upstream-configure-options
test "$(php-config --extension-dir)" = "$(/patched/usr/local/bin/php-config --extension-dir)"
test "$(/patched/usr/local/bin/php -n -r 'echo PHP_VERSION;')" = "$EXPECTED_PHP_VERSION"
mkdir -p /tmp/patched-source/php
tar -xJf /usr/src/php.tar.xz -C /tmp/patched-source/php --strip-components=1
bash /tmp/build/apply-patches.sh /tmp/patched-source/php
tar -cJf /tmp/php-patched.tar.xz -C /tmp/patched-source php
3 changes: 3 additions & 0 deletions php-nginx/7.4/.scripts/patches.list
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
0062-Introduce-max_multipart_body_parts-INI.patch
0136-GHSA-m8rr-4c36-8gq4-Consistently-pass-unsigned-char-.patch main/rfc1867.c
0164-Avoid-state-corruption-via-bailout.patch
127 changes: 127 additions & 0 deletions php-nginx/7.4/.scripts/smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import json
import pathlib
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request

image = sys.argv[1] if len(sys.argv) > 1 else "devitools/php-nginx:7.4"


def docker(*args, check=True):
return subprocess.run(["docker", *args], check=check, text=True, capture_output=True)


def require(condition, message):
if not condition:
raise RuntimeError(message)


with tempfile.TemporaryDirectory(prefix="php73-sury-") as directory:
root = pathlib.Path(directory)
app = root / "app"
app.mkdir()
app.joinpath("index.php").write_text('''<?php
header('Content-Type: application/json');
$db = new PDO('sqlite::memory:');
$im = imagecreatetruecolor(2, 2);
ob_start();
imagepng($im);
$png = ob_get_clean();
$modules = get_loaded_extensions();
sort($modules);
echo json_encode([
'version' => PHP_VERSION,
'sapi' => PHP_SAPI,
'memory_limit' => ini_get('memory_limit'),
'mounted' => ini_get('precision'),
'upload_progress' => ini_get('session.upload_progress.enabled'),
'multipart_limit' => ini_get('max_multipart_body_parts'),
'sqlite' => $db->query('SELECT 42')->fetchColumn(),
'gd' => strlen($png) > 0,
'mbstring' => mb_strlen('á', 'UTF-8'),
'modules' => $modules,
'post' => $_POST,
'files' => count($_FILES),
]);
''')
app.joinpath("oom.php").write_text('''<?php
file_put_contents('/tmp/oom-entered', 'ran');
register_shutdown_function(function () { file_put_contents('/tmp/unsafe-shutdown', 'ran'); });
ini_set('memory_limit', '16M');
$x = str_repeat('x', 64 * 1024 * 1024);
''')
custom = root / "custom.ini"
custom.write_text("precision=12\n")
cid = docker(
"run", "-d", "--platform", "linux/amd64", "-p", "127.0.0.1::80",
"-e", "WEB_DOCUMENT_ROOT=/app/public", "-e", "PHP_MEMORY_LIMIT=96M",
"-v", f"{app}:/app/public:ro",
"-v", f"{custom}:/opt/docker/etc/php/php.ini:ro", image,
).stdout.strip()
try:
port = docker("port", cid, "80/tcp").stdout.strip().split(":")[-1]
base = f"http://127.0.0.1:{port}"
for attempt in range(60):
try:
with urllib.request.urlopen(base, timeout=2) as response:
data = json.load(response)
break
except (OSError, ValueError):
time.sleep(1)
else:
raise RuntimeError("Nginx/FPM did not become ready")
docker("exec", cid, "nginx", "-t")
docker("exec", cid, "php-fpm", "-t")
require(data["version"] == "7.4.33", "Wrong PHP version")
require(data["sapi"] == "fpm-fcgi", "HTTP is not using FPM")
require(data["memory_limit"] == "96M", "PHP environment not applied")
require(data["mounted"] == "12", "Mounted INI not applied")
require(data["upload_progress"] in ("", "0", False), "Upload progress is enabled")
require(data["multipart_limit"] == "-1", "Missing multipart prerequisite")
require(int(data["sqlite"]) == 42 and data["gd"] and data["mbstring"] == 1, "Extension functional check failed")
expected = json.loads(docker("exec", cid, "cat", "/usr/local/share/php-security/upstream-modules.json").stdout)
require((set(data["modules"]) - {"cgi-fcgi"}) == (set(expected) - {"cgi-fcgi"}), "HTTP extension parity failed: " + json.dumps({"expected": expected, "actual": data["modules"]}))
boundary = "php73-sury-smoke"
body = (
f"--{boundary}\r\nContent-Disposition: form-data; name=field\r\n\r\nvalue\r\n"
f"--{boundary}\r\nContent-Disposition: form-data; name=file; filename=test.txt\r\n"
f"Content-Type: text/plain\r\n\r\nsafe upload\r\n--{boundary}--\r\n"
).encode()
request = urllib.request.Request(base, data=body, headers={"Content-Type": f"multipart/form-data; boundary={boundary}"})
with urllib.request.urlopen(request, timeout=5) as response:
uploaded = json.load(response)
require(uploaded["post"] == {"field": "value"} and uploaded["files"] == 1, "Multipart upload failed")
try:
urllib.request.urlopen(base + "/oom.php", timeout=10).close()
except (OSError, urllib.error.HTTPError):
pass
require(docker("exec", cid, "test", "-f", "/tmp/oom-entered", check=False).returncode == 0, "FPM OOM fixture was not executed")
require(docker("exec", cid, "test", "!", "-e", "/tmp/unsafe-shutdown", check=False).returncode == 0, "Unsafe OOM shutdown callback executed")
with urllib.request.urlopen(base, timeout=10) as response:
require(json.load(response)["version"] == "7.4.33", "FPM did not recover after OOM")
cli = docker("exec", cid, "php", "/usr/local/share/php-security/verify.php")
print(cli.stdout.strip())
except Exception:
print(docker("logs", cid, check=False).stdout, file=sys.stderr)
print(docker("logs", cid, check=False).stderr, file=sys.stderr)
raise
finally:
docker("rm", "-f", cid, check=False)

oom = docker(
"run", "--rm", "--platform", "linux/amd64", "--entrypoint", "php", image,
"-d", "memory_limit=16M", "-r",
"register_shutdown_function(function () { echo 'UNSAFE_SHUTDOWN'; }); str_repeat('x', 64 * 1024 * 1024);",
check=False,
)
require(oom.returncode == 1 and "Allowed memory size" in oom.stderr, "OOM did not follow the patched immediate-exit path")
require("UNSAFE_SHUTDOWN" not in oom.stdout, "Unsafe CLI OOM shutdown callback executed")

result = docker(
"run", "--rm", "--platform", "linux/amd64", "-e", "PHP_DISMOD=redis,imagick",
image, "php", "-r", "exit(extension_loaded('redis') || extension_loaded('imagick') ? 1 : 0);",
)
print("Nginx/FPM HTTP, multipart, INI mounts, environment, extensions, OOM recovery and PHP_DISMOD passed")
17 changes: 17 additions & 0 deletions php-nginx/7.4/.scripts/verify.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php
$expected = json_decode(file_get_contents(__DIR__ . '/upstream-modules.json'), true);
$actual = get_loaded_extensions();
sort($actual);
if ($actual !== $expected) {
fwrite(STDERR, 'Extension mismatch: ' . json_encode(array_diff($expected, $actual)) . PHP_EOL);
exit(1);
}
if (PHP_VERSION !== '7.4.33' || PHP_ZTS || PHP_DEBUG || ini_get('session.upload_progress.enabled')) {
fwrite(STDERR, 'Unexpected PHP version, ABI or upload progress setting' . PHP_EOL);
exit(1);
}
if (ini_get('max_multipart_body_parts') === false) {
fwrite(STDERR, 'Missing multipart prerequisite patch' . PHP_EOL);
exit(1);
}
echo 'PHP version, extension parity and security configuration verified', PHP_EOL;
29 changes: 29 additions & 0 deletions php-nginx/7.4/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
FROM webdevops/php-nginx:7.4@sha256:e316baf2caa9e98ae39985db8203d83defa0422aaf052a5726925cdddd629821 AS upstream

FROM upstream AS builder

ARG EXPECTED_PHP_VERSION=7.4.33
ARG PHP_SOURCE_SHA256=924846abf93bc613815c55dd3f5809377813ac62a9ec4eb3778675b82a27b927
ARG SURY_SOURCE_URL=https://packages.sury.org/php/pool/main/p/php7.4/php7.4_7.4.33-31%2B0~20261021.121%2Bdebian12~1.gbpe9c428.debian.tar.xz
ARG SURY_SOURCE_SHA256=4747b79a87b89a2591497704df4b80e68c7e78b64fbf23c205dca331ebf83496
ARG BUILD_JOBS=2

COPY .scripts/build-php.sh .scripts/apply-patches.sh .scripts/patches.list /tmp/build/
RUN bash /tmp/build/build-php.sh

FROM upstream AS php-nginx

COPY --from=builder /patched/usr/local/ /usr/local/
COPY --from=builder /tmp/php-patched.tar.xz /usr/src/php.tar.xz
COPY --from=builder /security/ /usr/local/share/php-security/
COPY .scripts/verify.php /usr/local/share/php-security/verify.php

RUN rm -f /usr/src/php.tar.xz.asc \
&& printf 'session.upload_progress.enabled=0\n' > /usr/local/etc/php/conf.d/zz-security.ini \
&& php /usr/local/share/php-security/verify.php \
&& php-fpm -t

LABEL org.opencontainers.image.title="PHP 7.4 php-nginx with Sury security backports" \
org.opencontainers.image.source="https://github.com/devitools/dockerfile" \
devitools.php.security-fixes="CVE-2026-109618,CVE-2026-109619" \
devitools.php.patch-source="php7.4_7.4.33-31+0~20261021.121+debian12~1.gbpe9c428.debian.tar.xz"
32 changes: 32 additions & 0 deletions php-nginx/7.4/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# php-nginx:7.4 security compatibility image

`devitools/php-nginx:7.4` is a linux/amd64 replacement for `webdevops/php-nginx:7.4`. Merging this directory triggers the existing build-images workflow to publish exactly this tag; no latest alias is added.

## Security and provenance

The upstream image is pinned to `sha256:e316baf2caa9e98ae39985db8203d83defa0422aaf052a5726925cdddd629821` and PHP source to `924846abf93bc613815c55dd3f5809377813ac62a9ec4eb3778675b82a27b927`. PHP remains `7.4.33` and is rebuilt under `/usr/local` with its original configure options and compiler flags. Original trailing configure environment assignments are restored through the image's compiler environment rather than parsed as options.

The checksum-pinned [Sury source package](https://packages.sury.org/php/pool/main/p/php7.4/php7.4_7.4.33-31%2B0~20261021.121%2Bdebian12~1.gbpe9c428.debian.tar.xz) provides the CVE-2026-109618 / CVE-2026-109619 backport and these prerequisites:

- `0062-Introduce-max_multipart_body_parts-INI.patch`
- `0136-GHSA-m8rr-4c36-8gq4-Consistently-pass-unsigned-char-.patch main/rfc1867.c` (only the `main/rfc1867.c` diff section is applied)
- `0164-Avoid-state-corruption-via-bailout.patch`

Source package SHA-256: `4747b79a87b89a2591497704df4b80e68c7e78b64fbf23c205dca331ebf83496`.

Only the listed patches are applied, not the entire Sury package or its unrelated updates. Selected file sections are extracted without modifying their hunks. Full original patches, applied sections, source checksums, Sury changelog, configure options and the original extension inventory are retained in `/usr/local/share/php-security/`.

The builder recompiles CLI, FPM, OPcache and any shared extensions selected by the original configure command. Other extension binaries, including vendor loaders and debugging extensions when present, remain inherited from the pinned image. Updated headers and `/usr/src/php.tar.xz` support downstream extension builds. Obsolete signatures of the original source archive are removed.

`session.upload_progress.enabled=0` is set at startup through `zz-security.ini`, following the [PHP advisory](https://www.php.net/archive/2026.php). OOM now exits the worker immediately; shutdown callbacks may not run. The multipart part limit defaults to `max_input_vars + max_file_uploads`.

## Compatibility and validation

The original entrypoint, Supervisor, web server, FPM configuration, `/opt/docker` paths, `/app`, user IDs, ports, mounts and environment handling are inherited. The build requires exact CLI extension-name parity. The smoke test checks FPM extension parity allowing its SAPI-specific `cgi-fcgi` registration, web server/FPM configuration, HTTP, file upload, document root, mounted INI, memory environment values, PHP_DISMOD, OOM callback suppression and subsequent HTTP availability.

```sh
docker build --platform linux/amd64 -t devitools/php-nginx:7.4 php-nginx/7.4
python3 php-nginx/7.4/.scripts/smoke.py devitools/php-nginx:7.4
```

For deployment, replace the Compose image reference and recreate the container while keeping application configuration. Custom INI mounts must preserve the disabled upload-progress setting. Encoded applications, vendor tools and application-specific extension behavior need staging validation. PHP remains end of life; this is a compatibility bridge for the named CVEs, not a complete security update or a maintained PHP distribution.
3 changes: 3 additions & 0 deletions php-nginx/7.4/variants.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- target: php-nginx
suffix: ""
platforms: linux/amd64
Loading