diff --git a/.codex/project.md b/.codex/project.md index fdfd550..e090e2a 100644 --- a/.codex/project.md +++ b/.codex/project.md @@ -81,6 +81,7 @@ docker compose run --rm --no-deps proxy nginx -t docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm security-tests docker compose config --quiet ``` diff --git a/README.md b/README.md index d42707b..2a5a330 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ browser tests. - `certbot`: optional HTTP-01 certificate issue/renewal service. - `wp-cli`: opt-in bootstrap and maintenance service. - `tests/functional`: Playwright tests running only through the public virtual host. +- `tests/security`: containerized static checks for security-sensitive Compose and + Apache assumptions. See [architecture](docs/architecture.md) and [ADR-0001](docs/adr/0001-wordpress-single-page.md). @@ -224,6 +226,7 @@ docker compose run --rm --no-deps proxy nginx -t docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm security-tests docker compose config --quiet ``` diff --git a/docker-compose.test.yml b/docker-compose.test.yml index c253a15..620f3ae 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -42,6 +42,21 @@ services: - no-new-privileges:true pids_limit: 200 + security-tests: + image: wordpress:cli-2.12.0-php8.3 + init: true + user: "33:33" + command: ["/bin/sh", "/workspace/tests/security/check-compose.sh"] + working_dir: /workspace + volumes: + - .:/workspace:ro + profiles: ["test"] + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + pids_limit: 50 + volumes: test_db_data: test_wordpress_data: diff --git a/docker-compose.yml b/docker-compose.yml index f8ee75c..3ed080d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,8 +67,6 @@ services: networks: - web - data - ports: - - 8000:80 depends_on: db: condition: service_healthy diff --git a/docs/architecture.md b/docs/architecture.md index b2e7c0c..995edfe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ NGINX is the only public entry point for `azionelab.org`. It proxies HTTP to the official WordPress 7.0/PHP 8.3 Apache image over the private `web` network. WordPress connects to MariaDB 11.8 LTS over a separate internal `data` network. Neither WordPress -nor MariaDB publishes a host port. +nor MariaDB publishes a host port; automated security checks guard this assumption. The custom `azionelab` classic theme renders the public single page. Theme modifications store the hero, manifesto, laboratory, teacher, lesson, and contact fields. The @@ -16,6 +16,10 @@ activates the theme, configures the site, and creates realistic demo content. Ce another optional service, enabled only for direct deployments. It shares challenge and certificate volumes with NGINX but has no container-control access. +Apache includes a small defense-in-depth hardening file that denies uploaded PHP files, +direct `wp-config.php` requests, and direct access to selected internal WordPress PHP +paths even if a future routing mistake bypasses NGINX. + Persistent state lives in host-based bind mounts configured by `DB_DATA_PATH`, `WORDPRESS_DATA_PATH`, `LETSENCRYPT_DATA_PATH`, and `CERTBOT_CHALLENGES_PATH`. Functional tests replace the database and WordPress mounts with isolated Docker test diff --git a/docs/security.md b/docs/security.md index 3e88a2b..092eab6 100644 --- a/docs/security.md +++ b/docs/security.md @@ -9,8 +9,9 @@ - File editing is always disabled. Production also disables web-based core, theme, and plugin changes; patched images are rebuilt and redeployed instead. - XML-RPC and comments are disabled. NGINX blocks PHP execution below uploads, dotfiles, - and direct `wp-config.php` requests, and rate-limits login/public requests. Public - REST user enumeration and author archives are disabled. + and direct `wp-config.php` requests, and rate-limits login/public requests. Apache + also denies uploaded PHP files and direct access to sensitive WordPress internals as + defense in depth. Public REST user enumeration and author archives are disabled. - Security headers include CSP, same-origin framing, content-type protection, a strict referrer policy, and a restrictive Permissions Policy. WordPress compatibility still requires inline style/script CSP allowances; do not treat this CSP as an XSS sanitizer. @@ -20,7 +21,8 @@ no Docker socket, dropped capabilities, and narrowly scoped volumes. - Containers are not privileged and do not use host networking or the Docker socket. WordPress/Apache retains the capabilities needed by the official image internally, - but no WordPress port is published. + but no WordPress port is published. A containerized security test fails if the + WordPress service is configured with host-published ports. - Persistent state uses host-based bind mounts. Keep those paths outside the public web root, restrict host access, never make them world-writable, and run `./scripts/prepare-host-volumes.sh` when paths or image user IDs change. diff --git a/docs/testing.md b/docs/testing.md index 6709686..2e56653 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,6 +10,7 @@ docker compose run --rm --no-deps proxy nginx -t docker compose -f docker-compose.yml -f docker-compose.test.yml up --build -d db wordpress proxy docker compose -f docker-compose.yml -f docker-compose.test.yml --profile tools run --rm wp-cli /scripts/bootstrap.sh docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --build --rm functional-tests +docker compose -f docker-compose.yml -f docker-compose.test.yml --profile test run --rm security-tests docker compose config --quiet LETSENCRYPT_ENABLED=1 docker compose config --quiet ``` @@ -19,7 +20,9 @@ override replaces database and WordPress state with isolated Docker volumes, map `azionelab.org` to the internal proxy, and never publishes an extra port. Browser tests cover content and section order, contact actions, mobile overflow/navigation, semantic landmarks, image alternatives, admin routing, security headers, blocked sensitive -routes, and unknown virtual hosts. +routes, blocked uploaded PHP requests, and unknown virtual hosts. Security checks also +assert that WordPress does not publish host ports and that Apache hardening remains +installed in the WordPress image. Subjective visual review and a real-device accessibility audit remain manual release checks. diff --git a/tests/functional/tests/portal.spec.ts b/tests/functional/tests/portal.spec.ts index 459087d..05b2c76 100644 --- a/tests/functional/tests/portal.spec.ts +++ b/tests/functional/tests/portal.spec.ts @@ -101,6 +101,7 @@ test("protects the edge and exposes the WordPress admin", async ({ page, request expect((await request.get("/xmlrpc.php")).status()).toBe(403); expect((await request.get("/.env")).status()).toBe(404); expect((await request.get("/wp-config.php")).status()).toBe(404); + expect((await request.get("/wp-content/uploads/probe.php")).status()).toBe(403); expect((await request.get("/wp-json/wp/v2/users")).status()).toBe(404); await page.goto("/wp-admin/"); diff --git a/tests/security/check-compose.sh b/tests/security/check-compose.sh new file mode 100755 index 0000000..3f58295 --- /dev/null +++ b/tests/security/check-compose.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -eu + +compose_file="${1:-/workspace/docker-compose.yml}" + +awk ' + /^[[:space:]]{2}wordpress:/ { + in_wordpress = 1 + next + } + in_wordpress && /^[[:space:]]{2}[A-Za-z0-9_-]+:/ { + in_wordpress = 0 + } + in_wordpress && /^[[:space:]]{4}ports:/ { + print "The wordpress service must not publish host ports; route traffic through proxy only." > "/dev/stderr" + exit 1 + } +' "$compose_file" + +grep -q 'azionelab-apache-hardening.conf' /workspace/wordpress/Dockerfile || { + echo "The WordPress image must install Apache hardening rules." >&2 + exit 1 +} + +grep -q 'wp-content/(uploads|files)' /workspace/wordpress/apache-hardening.conf || { + echo "Apache hardening must block PHP execution below uploads/files." >&2 + exit 1 +} diff --git a/wordpress/Dockerfile b/wordpress/Dockerfile index 40835de..c9cc755 100644 --- a/wordpress/Dockerfile +++ b/wordpress/Dockerfile @@ -4,10 +4,12 @@ COPY php.ini /usr/local/etc/php/conf.d/azionelab.ini COPY .htaccess /opt/azionelab/.htaccess COPY entrypoint-wrapper.sh /usr/local/bin/azionelab-entrypoint COPY healthcheck.php /usr/local/bin/azionelab-healthcheck.php +COPY apache-hardening.conf /etc/apache2/conf-available/azionelab-apache-hardening.conf COPY theme/azionelab /opt/azionelab/theme COPY mu-plugins/azionelab-content.php /opt/azionelab/azionelab-content.php RUN sed -ri 's!^[[:space:]]*CustomLog .*!CustomLog /dev/null combined!' /etc/apache2/sites-available/000-default.conf \ + && a2enconf azionelab-apache-hardening \ && chmod 755 /usr/local/bin/azionelab-entrypoint \ && chmod 644 /usr/local/bin/azionelab-healthcheck.php \ && chown -R www-data:www-data /opt/azionelab diff --git a/wordpress/apache-hardening.conf b/wordpress/apache-hardening.conf new file mode 100644 index 0000000..c6d6674 --- /dev/null +++ b/wordpress/apache-hardening.conf @@ -0,0 +1,27 @@ +ServerTokens Prod +ServerSignature Off +TraceEnable Off + + + Require all denied + + + + Require all denied + + + + Require all denied + + + + Require all denied + + + + Require all denied + + + + Require all denied +