fix(php): drop the stale memory_limit mitigation and guard where it must reach FPM

Shopware moved to docker-base 8.3.36-caddy on 2026-10-31, the patched PHP
release, yet still mounted the CVE-2026-109618 memory_limit = -1 ini, so its
web FPM kept running without a limit; the mount and its TODO entry go, and
web falls back to the image's 512m like worker and scheduler. Magento passed
a PHP_MEMORY_LIMIT that no image in the stack reads and that would silently
override the mitigation if one ever did; it goes too.

A new lint keeps every remaining php_memory_unlimited mount in the scan
directory of the service's php_hardening mount, last in sort order, and
rejects any pool, PHP_VALUE or env override of memory_limit in the role.

Refs #1292

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-11-03 15:42:43 +01:00
1 parent d807d1b6b4
commit d601adbf9e
4 files changed
+289 -11

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@
- Implement rbac administration interface
- Enable IP6 for docker.
- backup docker to local für ca optimieren
- Drop the `php_memory_unlimited` mount ([memory-unlimited.ini](roles/sys-svc-compose-php/files/memory-unlimited.ini), `memory_limit = -1`) from each role whose image ships PHP 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later, and the file, its install task and `FILE_PHP_MEMORY_UNLIMITED_INI` once no role mounts it. It is the interim mitigation for CVE-2026-109618 and overrides the per-role `service_memory_mb` share, so a single request can exhaust the container instead of hitting a PHP limit. Still mounted by `web-app-magento` (`markoshust/magento-php:8.4-fpm`, PHP 8.4.21), `web-app-mailu` (`ghcr.io/heviat/webmail:2024.06`, the OIDC flavor, built 2025-04-04), `web-app-phpldapadmin` (`phpldapadmin/phpldapadmin:2.3.11`, built 2026-05-15) and `web-app-shopware` (`ghcr.io/shopware/docker-base:8.3`, PHP 8.3.25). `web-app-fusiondirectory` (`nfrastack/fusiondirectory:1.5`, PHP 7.4.33 on Alpine 3.15, end of life and never patched) carries it as `PHP_MEMORY_LIMIT=-1` in `templates/env.j2` instead (the image's pool sets `php_admin_value[memory_limit]` from it, so a conf.d ini cannot reach FPM) and drops it under the same rule. `web-app-magento` keeps the 756M its `nginx.conf.sample` (`PHP_VALUE`) and `pub/.user.ini` set and keeps `post_max_size` below a quarter of that limit.
- Drop the `php_memory_unlimited` mount ([memory-unlimited.ini](roles/sys-svc-compose-php/files/memory-unlimited.ini), `memory_limit = -1`) from each role whose image ships PHP 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later, and the file, its install task, `FILE_PHP_MEMORY_UNLIMITED_INI` and [its lint](tests/lint/ansible/roles/meta/test_php_memory_unlimited_reaches_fpm.py) once no role mounts it. It is the interim mitigation for CVE-2026-109618 and overrides the per-role `service_memory_mb` share, so a single request can exhaust the container instead of hitting a PHP limit. Still mounted by `web-app-magento` (`markoshust/magento-php:8.4-fpm`, PHP 8.4.21), `web-app-mailu` (`ghcr.io/heviat/webmail:2024.06`, the OIDC flavor, built 2025-04-04) and `web-app-phpldapadmin` (`phpldapadmin/phpldapadmin:2.3.11`, built 2026-05-15). `web-app-fusiondirectory` (`nfrastack/fusiondirectory:1.5`, PHP 7.4.33 on Alpine 3.15, end of life and never patched) carries it as `PHP_MEMORY_LIMIT=-1` in `templates/env.j2` instead (the image's pool sets `php_admin_value[memory_limit]` from it, so a conf.d ini cannot reach FPM) and drops it under the same rule. `web-app-magento` keeps the 756M its `nginx.conf.sample` (`PHP_VALUE`) and `pub/.user.ini` set and keeps `post_max_size` below a quarter of that limit.
- Fork [rpardini/docker-registry-proxy](https://github.com/rpardini/docker-registry-proxy) and teach the bundled nginx to handle GCP Artifact Registry redirect URLs (`/artifacts-downloads/namespaces/<ns>/repositories/<repo>/downloads/<token>`). Currently `gcr.io` blob pulls are bypassed via `NO_PROXY=…,gcr.io,storage.googleapis.com,googleusercontent.com` in [compose/registry-cache/proxy.conf](compose/registry-cache/proxy.conf), which is a workaround that disables caching for those registries. Long-term: rebase upstream's `proxy.conf` / `sub_filter` rules to recognise the new redirect shape, ship the fix in our fork, point `compose/registry-cache/Dockerfile` (or its image tag) at the fork, and drop the `NO_PROXY` bypass.
## Testing
-2
View File
@@ -41,6 +41,4 @@ MAGENTO_REMOTE_STORAGE_REGION={{ lookup('objstore', application_id, 'region') }}
MAGENTO_REMOTE_STORAGE_PATH_STYLE=1
{% endif %}
PHP_MEMORY_LIMIT={{ lookup('service_memory_mb', application_id, 'php', 0.75) }}M
APACHE_SERVERNAME={{ MAGENTO_DOMAIN }}
-8
View File
@@ -131,14 +131,6 @@ php_hardening:
- service: web
target: /usr/local/etc/php/conf.d/zz-infinito-hardening.ini
php_memory_unlimited:
type: config
source: "{{ FILE_PHP_MEMORY_UNLIMITED_INI }}"
mode: "0444"
mounts:
- service: web
target: /usr/local/etc/php/conf.d/zz-infinito-memory-unlimited.ini
php_fpm_hardening:
type: config
source: "{{ FILE_PHP_FPM_HARDENING_CONF }}"
@@ -0,0 +1,288 @@
"""Lint guard: the interim ``memory_limit = -1`` ini MUST reach PHP-FPM.
A role carries the CVE-2026-109618 mitigation by mounting
``FILE_PHP_MEMORY_UNLIMITED_INI`` (``php_memory_unlimited`` in ``meta/volumes.yml``)
into the conf.d of its image. FPM keeps ``-1`` only while nothing later sets
``memory_limit`` again: a later ini in the same scan dir, a pool ``php_value`` or
``php_admin_value``, a FastCGI ``PHP_VALUE`` or ``PHP_ADMIN_VALUE``, or a
``PHP_MEMORY_LIMIT`` env that the image writes into its pool as
``php_admin_value`` (``nfrastack/fusiondirectory`` does).
Each mount MUST share the directory of the same service's ``php_hardening`` mount,
the scan dir ``sys-ctl-hlth-runtime`` verifies on the live container, and MUST sort
after every other ini the role mounts there. No file under the role's
``templates/`` or ``files/`` may set a ``memory_limit`` other than ``-1`` through
the pool or FastCGI, and ``templates/env.j2`` and ``templates/compose.yml.j2`` may
not pass a ``PHP_MEMORY_LIMIT`` other than ``-1``. Overrides the image itself ships,
or upstream files the role copies at runtime (Magento's ``nginx.conf.sample``), are
outside the repository and stay a runtime check.
"""
from __future__ import annotations
import re
import unittest
from collections import defaultdict
from pathlib import Path, PurePosixPath
from typing import TYPE_CHECKING, Any, ClassVar
from utils.cache.files import iter_project_files, read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.mapping import (
ROLE_FILE_META_VOLUMES,
ROLE_FILE_TEMPL_COMPOSE,
)
from . import PROJECT_ROOT
if TYPE_CHECKING:
from collections.abc import Mapping
MITIGATION = "php_memory_unlimited"
HARDENING = "php_hardening"
ENV_TEMPLATES = ("templates/env.j2", ROLE_FILE_TEMPL_COMPOSE)
SCANNED_DIRS = ("templates", "files")
_UNLIMITED = "-1"
_COMMENT = ("#", ";", "//", "{#")
_POOL = re.compile(
r"\bphp_(?:admin_)?value\s*\[\s*memory_limit\s*\]\s*=\s*(?P<value>[^\s;]+)",
re.IGNORECASE,
)
_FASTCGI = re.compile(
r"\bPHP_(?:ADMIN_)?VALUE\b.*?\bmemory_limit\s*=\s*(?P<value>[^\s\"';\\]+)"
)
_ENV = re.compile(r"^\s*(?:-\s*)?[\"']?PHP_MEMORY_LIMIT[\"']?\s*[=:]\s*(?P<value>\S+)")
def _limit(value: str) -> str:
return value.strip().strip("\"'")
def mount_findings(volumes: Mapping[str, Any]) -> list[str]:
"""Return why the mitigation mounts of one role cannot reach PHP-FPM.
Args:
volumes: the parsed ``meta/volumes.yml`` of the role.
"""
scan_dirs = {
mount["service"]: PurePosixPath(mount["target"]).parent
for mount in volumes.get(HARDENING, {}).get("mounts") or []
}
inis: dict[tuple[str, PurePosixPath], list[str]] = defaultdict(list)
for entry in volumes.values():
if not isinstance(entry, dict):
continue
for mount in entry.get("mounts") or []:
target = PurePosixPath(str(mount.get("target", "")))
if target.suffix == ".ini":
inis[(mount.get("service"), target.parent)].append(target.name)
findings = []
for mount in volumes[MITIGATION].get("mounts") or []:
service = mount["service"]
target = PurePosixPath(mount["target"])
if service not in scan_dirs:
findings.append(
f"{service}: no {HARDENING} mount, so nothing verifies "
f"{target.parent} as the scan dir"
)
continue
if target.parent != scan_dirs[service]:
findings.append(
f"{service}: {target} is outside the scan dir {scan_dirs[service]}"
)
later = sorted(n for n in inis[(service, target.parent)] if n > target.name)
if later:
findings.append(
f"{service}: {', '.join(later)} sorts after {target.name} and wins"
)
return findings
def text_findings(relative: str, text: str) -> list[str]:
"""Return the lines of one role file that set ``memory_limit`` after the ini.
Args:
relative: the file's path inside its role, e.g. ``templates/env.j2``.
text: the file's content.
"""
patterns = [_POOL, _FASTCGI]
if relative in ENV_TEMPLATES:
patterns.append(_ENV)
findings = []
for number, line in enumerate(text.splitlines(), start=1):
if line.lstrip().startswith(_COMMENT):
continue
for pattern in patterns:
match = pattern.search(line)
if match and _limit(match["value"]) != _UNLIMITED:
findings.append(f"{relative}:{number}: {line.strip()}")
return findings
def carriers() -> dict[str, dict[str, Any]]:
"""Return ``{role: volumes}`` for every role that mounts the mitigation."""
found = {}
for path in sorted((PROJECT_ROOT / "roles").glob(f"*/{ROLE_FILE_META_VOLUMES}")):
volumes = load_yaml_any(str(path), default_if_missing={})
if isinstance(volumes, dict) and MITIGATION in volumes:
found[path.parent.parent.name] = volumes
return found
def role_files(role: str) -> list[tuple[str, str]]:
"""Return ``(path inside the role, text)`` for the role's scanned files.
Args:
role: the role directory name.
"""
root = PROJECT_ROOT / "roles" / role
files = []
for path in iter_project_files(exclude_tests=True):
relative = Path(path).relative_to(PROJECT_ROOT)
if relative.parts[:2] != ("roles", role) or len(relative.parts) < 4:
continue
if relative.parts[2] not in SCANNED_DIRS:
continue
try:
text = read_text(path)
except UnicodeDecodeError:
continue
files.append((Path(path).relative_to(root).as_posix(), text))
return files
class TestPhpMemoryUnlimitedReachesFpm(unittest.TestCase):
def test_every_carrier_lets_the_ini_reach_fpm(self) -> None:
roles = carriers()
self.assertTrue(
roles,
f"no role mounts {MITIGATION} any more: delete this lint together "
"with memory-unlimited.ini, its install task and "
"FILE_PHP_MEMORY_UNLIMITED_INI",
)
offenders = []
for role, volumes in roles.items():
findings = mount_findings(volumes)
for relative, text in role_files(role):
findings += text_findings(relative, text)
offenders += [f"- roles/{role}: {finding}" for finding in findings]
self.assertEqual(
offenders,
[],
"These roles mount the memory_limit = -1 ini but also set "
"memory_limit where PHP-FPM reads it after that ini:\n"
+ "\n".join(offenders),
)
class TestMountFindings(unittest.TestCase):
HARDENED: ClassVar[dict[str, Any]] = {
HARDENING: {
"mounts": [
{
"service": "php",
"target": "/etc/php/conf.d/zz-infinito-hardening.ini",
}
]
},
MITIGATION: {
"mounts": [
{
"service": "php",
"target": "/etc/php/conf.d/zz-infinito-memory-unlimited.ini",
}
]
},
}
def test_a_mount_beside_the_hardening_ini_passes(self) -> None:
self.assertEqual(mount_findings(self.HARDENED), [])
def test_a_mount_outside_the_scan_dir_is_flagged(self) -> None:
volumes = {
**self.HARDENED,
MITIGATION: {
"mounts": [{"service": "php", "target": "/etc/php/zz-memory.ini"}]
},
}
self.assertEqual(
mount_findings(volumes),
["php: /etc/php/zz-memory.ini is outside the scan dir /etc/php/conf.d"],
)
def test_a_service_without_hardening_is_flagged(self) -> None:
volumes = {MITIGATION: self.HARDENED[MITIGATION]}
self.assertEqual(len(mount_findings(volumes)), 1)
def test_a_later_ini_in_the_scan_dir_is_flagged(self) -> None:
volumes = {
**self.HARDENED,
"app_ini": {
"mounts": [
{"service": "php", "target": "/etc/php/conf.d/zz-uploads.ini"}
]
},
}
self.assertEqual(
mount_findings(volumes),
[
"php: zz-uploads.ini sorts after zz-infinito-memory-unlimited.ini and wins"
],
)
def test_a_later_ini_of_another_service_is_ignored(self) -> None:
volumes = {
**self.HARDENED,
"app_ini": {
"mounts": [
{"service": "cron", "target": "/etc/php/conf.d/zz-uploads.ini"}
]
},
}
self.assertEqual(mount_findings(volumes), [])
class TestTextFindings(unittest.TestCase):
def test_a_pool_admin_value_is_flagged(self) -> None:
text = "[www]\nphp_admin_value[memory_limit] = 128M\n"
self.assertEqual(
text_findings("templates/php-fpm.conf.j2", text),
["templates/php-fpm.conf.j2:2: php_admin_value[memory_limit] = 128M"],
)
def test_a_fastcgi_php_value_is_flagged(self) -> None:
line = 'fastcgi_param PHP_VALUE "memory_limit=756M \\n max_execution_time=600";'
self.assertEqual(len(text_findings("templates/nginx.conf.j2", line)), 1)
def test_the_former_magento_env_line_is_flagged(self) -> None:
line = (
"PHP_MEMORY_LIMIT={{ lookup('service_memory_mb', application_id, "
"'php', 0.75) }}M"
)
self.assertEqual(len(text_findings("templates/env.j2", line)), 1)
def test_a_compose_environment_entry_is_flagged(self) -> None:
line = " PHP_MEMORY_LIMIT: 512M"
self.assertEqual(len(text_findings(ROLE_FILE_TEMPL_COMPOSE, line)), 1)
def test_unlimited_values_pass(self) -> None:
text = (
"PHP_MEMORY_LIMIT=-1\n"
"php_admin_value[memory_limit] = -1\n"
'fastcgi_param PHP_ADMIN_VALUE "memory_limit=-1";'
)
self.assertEqual(text_findings("templates/env.j2", text), [])
def test_a_shell_variable_in_a_script_is_not_env(self) -> None:
line = 'PHP_MEMORY_LIMIT="${INIT_PHP_MEMORY_LIMIT:?}"'
self.assertEqual(text_findings("files/shell/init.sh", line), [])
def test_a_commented_override_is_ignored(self) -> None:
line = "; php_admin_value[memory_limit] = 1G"
self.assertEqual(text_findings("templates/php-fpm.conf.j2", line), [])
if __name__ == "__main__": # pragma: no cover
unittest.main()