From 42533543348f894fc6e83146824d7c4437733be6 Mon Sep 17 00:00:00 2001 From: FlipFlop1974 Date: Wed, 5 Aug 2026 09:17:08 +0200 Subject: [PATCH 1/8] fix: harden consent integration and releases --- .github/workflows/quality.yml | 16 +++- .github/workflows/release.yml | 66 ++++++++++++++++ .gitignore | 3 + .planning/PROJECT.md | 15 ++++ .planning/REQUIREMENTS.md | 12 +++ .planning/ROADMAP.md | 15 ++++ .planning/STATE.md | 24 ++++++ .../260805-sec-security-hardening-rc2/PLAN.md | 14 ++++ .../SUMMARY.md | 14 ++++ .../VERIFICATION.md | 15 ++++ CHANGELOG.md | 10 +++ README.md | 39 ++++++++-- SECURITY.md | 4 + _build/build.config.sample.php | 2 +- _build/build.transport.php | 10 ++- _build/data/transport.settings.php | 2 - _build/data/transport.snippets.php | 8 ++ _build/resolvers/resolve.settings.php | 24 ------ _build/validators/validate.requirements.php | 41 ++++++++++ .../amwebconsent/js/amwebconsent.js | 15 ++++ .../controllers/mgr/index.class.php | 2 +- .../elements/chunks/amwebconsent.config.tpl | 14 ++-- .../elements/chunks/amwebconsent.footer.tpl | 7 +- .../elements/chunks/amwebconsent.head.tpl | 1 + .../amwebconsent.example.recaptcha-v3.tpl | 4 +- .../snippets/amwebconsent.snippet.php | 4 +- .../snippets/amwebconsentjson.snippet.php | 10 +++ .../snippets/amwebconsentlink.snippet.php | 9 +-- .../amwebconsent/lexicon/de/default.inc.php | 4 - .../amwebconsent/lexicon/en/default.inc.php | 4 - .../model/amwebconsent/amwebconsent.class.php | 51 ++++++++---- package-lock.json | 78 +++++++++++++++++++ package.json | 12 +++ playwright.config.js | 18 +++++ scripts/normalize-transport.py | 58 ++++++++++++++ tests/e2e/consent.spec.js | 60 ++++++++++++++ tests/fixtures/consent.html | 18 +++++ tests/fixtures/csp.html | 20 +++++ tests/fixtures/modx-config.xml | 27 +++++++ tests/integration/package-lifecycle.php | 74 ++++++++++++++++++ tests/server.mjs | 45 +++++++++++ 41 files changed, 789 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .planning/PROJECT.md create mode 100644 .planning/REQUIREMENTS.md create mode 100644 .planning/ROADMAP.md create mode 100644 .planning/STATE.md create mode 100644 .planning/quick/260805-sec-security-hardening-rc2/PLAN.md create mode 100644 .planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md create mode 100644 .planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md delete mode 100644 _build/resolvers/resolve.settings.php create mode 100644 _build/validators/validate.requirements.php create mode 100644 assets/components/amwebconsent/js/amwebconsent.js create mode 100644 core/components/amwebconsent/elements/snippets/amwebconsentjson.snippet.php create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 scripts/normalize-transport.py create mode 100644 tests/e2e/consent.spec.js create mode 100644 tests/fixtures/consent.html create mode 100644 tests/fixtures/csp.html create mode 100644 tests/fixtures/modx-config.xml create mode 100644 tests/integration/package-lifecycle.php create mode 100644 tests/server.mjs diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 1773f13..5c0fd1c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -14,9 +14,21 @@ jobs: matrix: php: ['7.4', '8.1', '8.2', '8.3'] steps: - - uses: actions/checkout@v4 - - uses: shivammathur/setup-php@v2 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5 with: php-version: ${{ matrix.php }} - name: Lint PHP files run: find . -type f -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l + + browser-security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..11b08a2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Build release candidate + +on: + workflow_dispatch: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + transport-package: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + attestations: write + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: modx + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # 2.35.5 + with: + php-version: '8.2' + extensions: pdo_mysql, zip, mbstring, json + tools: composer:v2 + - name: Install pinned MODX build runtime + run: composer create-project modx/revolution /tmp/modx 3.2.3-pl --no-interaction --prefer-dist + - name: Install MODX non-interactively + run: | + cp tests/fixtures/modx-config.xml /tmp/modx/setup/config.xml + php /tmp/modx/setup/index.php --installmode=new --config=/tmp/modx/setup/config.xml + - name: Build twice and verify reproducibility + run: | + cp _build/build.config.sample.php _build/build.config.php + mkdir -p dist + MODX_BASE_PATH=/tmp/modx/ php _build/build.transport.php + python3 scripts/normalize-transport.py /tmp/modx/core/packages/amwebconsent-1.0.0-rc2.transport.zip /tmp/first.transport.zip + MODX_BASE_PATH=/tmp/modx/ php _build/build.transport.php + python3 scripts/normalize-transport.py /tmp/modx/core/packages/amwebconsent-1.0.0-rc2.transport.zip /tmp/second.transport.zip + cmp /tmp/first.transport.zip /tmp/second.transport.zip + cp /tmp/first.transport.zip dist/amwebconsent-1.0.0-rc2.transport.zip + sha256sum dist/amwebconsent-1.0.0-rc2.transport.zip > dist/amwebconsent-1.0.0-rc2.transport.zip.sha256 + - name: Verify package lifecycle on clean MODX + run: MODX_BASE_PATH=/tmp/modx/ php tests/integration/package-lifecycle.php "$PWD/dist/amwebconsent-1.0.0-rc2.transport.zip" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: amwebconsent-transport + path: dist/* + if-no-files-found: error + - name: Attest build provenance + if: startsWith(github.ref, 'refs/tags/') + uses: actions/attest-build-provenance@43d14bc2b83dec42d39ecae14e916627a18bb661 # v3 + with: + subject-path: dist/amwebconsent-1.0.0-rc2.transport.zip diff --git a/.gitignore b/.gitignore index 775a1ba..d2cdd83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /.DS_Store /_build/build.config.php /dist/ +/node_modules/ +/playwright-report/ +/test-results/ /vendor/ diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md new file mode 100644 index 0000000..5cdcd6c --- /dev/null +++ b/.planning/PROJECT.md @@ -0,0 +1,15 @@ +# amWeb Consent (powered by Klaro) + +Open-source MODX 3 Extra by amWeb that integrates the vendored Klaro consent manager. The project targets MODX 3.x and PHP 7.4+, is licensed under MIT, and is hosted at https://github.com/amWeb-DevTeam/amweb-consent. + +## Product constraints + +- Consent-controlled services must not contact third parties before opt-in. +- Runtime paths come from MODX constants/options, not editable component settings. +- Dynamic JavaScript values use JSON encoding. +- Strict CSP deployments can pass a nonce to every generated script. +- Release candidates are built and tested on GitHub before MODX submission. + +## Current milestone + +Prepare `1.0.0-rc2` as the security-hardened public release candidate. diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md new file mode 100644 index 0000000..993b950 --- /dev/null +++ b/.planning/REQUIREMENTS.md @@ -0,0 +1,12 @@ +# Requirements + +## Release candidate 1.0.0-rc2 + +- [x] SEC-01 Escape reCAPTCHA values according to URL and JavaScript context. +- [x] SEC-02 Support nonce-based CSP and remove inline event handlers. +- [x] SEC-03 Reject unsupported MODX/PHP versions during installation. +- [x] SEC-04 Prove in a browser that blocked services make no request before consent. +- [x] SEC-05 Pin CI actions and produce a normalized, reproducible transport package. +- [ ] REL-01 Pass the clean MODX install/uninstall job on GitHub Actions. +- [ ] REL-02 Review and merge the hardening pull request. +- [ ] REL-03 Publish and verify the `1.0.0-rc2` prerelease asset and checksum. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md new file mode 100644 index 0000000..b98da5a --- /dev/null +++ b/.planning/ROADMAP.md @@ -0,0 +1,15 @@ +# Roadmap + +## Phase 1 — Public RC foundation (complete) + +Repository, MIT licensing, English documentation, MODX 3 package structure, and `1.0.0-rc1` prerelease. + +## Phase 2 — Security hardening (in progress) + +Harden render contexts and paths, add CSP support and install validation, automate browser security tests, and make the release build reproducible. + +Success criteria: SEC-01 through SEC-05 pass locally and REL-01 passes in GitHub Actions. + +## Phase 3 — Official MODX submission (pending) + +Publish the verified release candidate, collect acceptance feedback, then submit the Extra to the MODX provider. diff --git a/.planning/STATE.md b/.planning/STATE.md new file mode 100644 index 0000000..b951558 --- /dev/null +++ b/.planning/STATE.md @@ -0,0 +1,24 @@ +# Project state + +- Milestone: `1.0.0-rc2` +- Active phase: 2 — Security hardening +- Active branch: `codex/security-hardening-rc2` +- Last verified: 2026-08-05 +- Next action: push a draft PR and let the clean MODX package-lifecycle job run. + +## Verified locally + +- npm audit: 0 vulnerabilities +- Playwright: 3/3 passed +- PHP syntax: passed in the MODX DDEV runtime +- MODX transport build: passed twice +- Normalized artifacts: byte-identical +- Current artifact SHA-256: `fea322a43937f6945efd908a881463fac199a2aca2eede188ca849c2d1f321a9` + +## Decisions + +- Only MODX 3.x is supported. +- Minimum PHP is 7.4. +- Component `core_path` and `assets_url` settings are no longer trusted or used. +- Config chunks contain JavaScript only; the renderer owns the script tag and nonce. +- Playwright is pinned to 1.62.1 due to GHSA-7mvr-c777-76hp in older versions. diff --git a/.planning/quick/260805-sec-security-hardening-rc2/PLAN.md b/.planning/quick/260805-sec-security-hardening-rc2/PLAN.md new file mode 100644 index 0000000..9bcac04 --- /dev/null +++ b/.planning/quick/260805-sec-security-hardening-rc2/PLAN.md @@ -0,0 +1,14 @@ +# Quick task: Security hardening for rc2 + +## Goal + +Resolve the five security review findings with minimal process overhead while preserving verifiable release gates. + +## Tasks + +1. Harden context encoding and eliminate configurable runtime paths. +2. Add nonce-based CSP output and externalize the settings-link handler. +3. Add MODX/PHP package requirements and an install validator. +4. Add browser tests for consent network blocking and strict CSP. +5. Pin CI dependencies, normalize MODX randomness, and verify duplicate builds. +6. Add a clean MODX install/uninstall gate to the release workflow. diff --git a/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md b/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md new file mode 100644 index 0000000..2d8fff5 --- /dev/null +++ b/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md @@ -0,0 +1,14 @@ +# Summary + +All five requested hardening areas were implemented for `1.0.0-rc2`. + +- JavaScript values are JSON-encoded; the reCAPTCHA URL key is URL-encoded. +- Generated scripts accept a CSP nonce; inline `onclick` was replaced by an external listener. +- Editable component path settings were removed from runtime use and package settings. +- Package metadata and a PHP validator enforce MODX 3.x, PHP 7.4+, and JSON support. +- Playwright proves no pre-consent/rejected network request and validates strict CSP operation. +- GitHub actions and Node test dependencies are pinned. +- MODX-generated random vehicle IDs, ZIP timestamps, metadata, and ordering are normalized. +- The release workflow builds twice, compares artifacts, tests clean install/uninstall, uploads a checksum, and emits provenance for tag builds. + +The only remaining validation is the GitHub-hosted clean MODX lifecycle run before merge/release. diff --git a/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md b/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md new file mode 100644 index 0000000..28633dd --- /dev/null +++ b/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md @@ -0,0 +1,15 @@ +# Verification + +Status: locally passed; hosted lifecycle gate pending. + +| Gate | Result | +|---|---| +| npm audit | PASS — 0 vulnerabilities | +| Browser consent tests | PASS — 3/3 | +| Strict CSP browser test | PASS | +| PHP syntax | PASS | +| MODX package build | PASS | +| Reproducible normalized ZIP | PASS | +| Clean MODX install/uninstall | PENDING — GitHub Actions | + +Artifact SHA-256: `fea322a43937f6945efd908a881463fac199a2aca2eede188ca849c2d1f321a9` diff --git a/CHANGELOG.md b/CHANGELOG.md index b3dba27..c5ec9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.0.0-rc2 + +- Safely JSON-encode dynamic JavaScript values and reCAPTCHA settings. +- Remove configurable PHP and asset paths from runtime loading. +- Add CSP nonce support and replace inline click handlers. +- Route split head/footer rendering through the secure PHP renderer. +- Add MODX 3, PHP 7.4 and JSON installation validation. +- Add browser tests proving trackers stay blocked before consent and rejection. +- Pin GitHub Actions and add reproducible package builds with provenance. + ## 1.0.0-rc1 - First public release candidate as amWeb Consent (powered by Klaro). diff --git a/README.md b/README.md index f37c2cf..946185b 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Snippets: - `amWebConsent` - `amWebConsentLink` +- `amWebConsentJson` (internal output filter) Chunks: @@ -69,8 +70,6 @@ System settings: - `amwebconsent.load_css` - `amwebconsent.load_js` - `amwebconsent.debug` -- `amwebconsent.assets_url` -- `amwebconsent.core_path` ## Basic Installation @@ -98,6 +97,14 @@ Split head/footer integration: [[$amwebconsent.footer]] ``` +The split chunks accept a request-specific `nonce` placeholder. Pass the same +nonce used in your Content Security Policy header: + +```html +[[$amwebconsent.head? &nonce=`[[+cspNonce]]`]] +[[$amwebconsent.footer? &nonce=`[[+cspNonce]]`]] +``` + Manual consent settings link: ```html @@ -123,10 +130,12 @@ Button variant: | Property | Default | Description | | --- | --- | --- | -| `configChunk` | `amwebconsent.config` | Chunk that returns `window.klaroConfig`. | +| `configChunk` | `amwebconsent.config` | Chunk that returns JavaScript only; the snippet creates the script element. | | `loadCss` | system setting | Load local Klaro CSS. | | `loadJs` | system setting | Load local Klaro JS. | +| `renderConfig` | `1` | Render the Klaro configuration script. Set to `0` in footer-only calls. | | `autoLoad` | `1` | If `0`, writes `noAutoLoad: true` to the config. | +| `nonce` | empty | Request-specific CSP nonce applied to every script element. | | `debug` | system setting | Adds a small HTML comment and logs empty config output. | Example: @@ -137,9 +146,13 @@ Example: &loadCss=`1` &loadJs=`1` &autoLoad=`1` + &nonce=`[[+cspNonce]]` ]] ``` +Custom configuration chunks must return JavaScript only, without surrounding +` diff --git a/core/components/amwebconsent/elements/chunks/amwebconsent.footer.tpl b/core/components/amwebconsent/elements/chunks/amwebconsent.footer.tpl index a13d060..4f5a952 100644 --- a/core/components/amwebconsent/elements/chunks/amwebconsent.footer.tpl +++ b/core/components/amwebconsent/elements/chunks/amwebconsent.footer.tpl @@ -1 +1,6 @@ - +[[!amWebConsent? + &loadCss=`0` + &loadJs=`1` + &renderConfig=`0` + &nonce=`[[+nonce]]` +]] diff --git a/core/components/amwebconsent/elements/chunks/amwebconsent.head.tpl b/core/components/amwebconsent/elements/chunks/amwebconsent.head.tpl index a98f221..87c4d59 100644 --- a/core/components/amwebconsent/elements/chunks/amwebconsent.head.tpl +++ b/core/components/amwebconsent/elements/chunks/amwebconsent.head.tpl @@ -1,4 +1,5 @@ [[!amWebConsent? &loadCss=`1` &loadJs=`0` + &nonce=`[[+nonce]]` ]] diff --git a/core/components/amwebconsent/elements/chunks/examples/amwebconsent.example.recaptcha-v3.tpl b/core/components/amwebconsent/elements/chunks/examples/amwebconsent.example.recaptcha-v3.tpl index 30c0a53..ea76bd4 100644 --- a/core/components/amwebconsent/elements/chunks/examples/amwebconsent.example.recaptcha-v3.tpl +++ b/core/components/amwebconsent/elements/chunks/examples/amwebconsent.example.recaptcha-v3.tpl @@ -2,11 +2,11 @@ type="text/plain" data-type="text/javascript" data-name="google-recaptcha" - data-src="https://www.google.com/recaptcha/api.js?render=[[++recaptcha.site_key]]"> + data-src="https://www.google.com/recaptcha/api.js?render=[[++recaptcha.site_key:urlencode]]"> '; + } } - $output[] = $config; if ($loadJs) { - $output[] = ''; + $output[] = ''; + $output[] = ''; } return implode("\n", array_filter($output)) . "\n"; @@ -110,6 +117,15 @@ protected function normalizeLanguage($language): string return 'de'; } + protected function normalizeStorageMethod($storageMethod): string + { + $storageMethod = (string) $storageMethod; + + return in_array($storageMethod, ['cookie', 'localStorage', 'sessionStorage', 'test'], true) + ? $storageMethod + : 'cookie'; + } + protected function getSetting(string $key, $default = null) { return $this->modx->getOption($key, $this->config, $default); @@ -144,12 +160,13 @@ protected function escapeHtml($value): string return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } - protected function escapeJs($value): string + protected function encodeJson($value): string { - return str_replace( - ["\\", "'", "\r", "\n", '=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a1b2ce --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "amweb-consent", + "version": "1.0.0-rc.2", + "private": true, + "scripts": { + "test": "playwright test", + "test:e2e": "playwright test tests/e2e" + }, + "devDependencies": { + "@playwright/test": "1.62.1" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..ad79819 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,18 @@ +const { defineConfig } = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './tests/e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + browserName: 'chromium', + }, + webServer: { + command: 'node tests/server.mjs', + url: 'http://127.0.0.1:4173/health', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/scripts/normalize-transport.py b/scripts/normalize-transport.py new file mode 100644 index 0000000..cf50376 --- /dev/null +++ b/scripts/normalize-transport.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Rewrite a MODX transport ZIP with stable IDs, order and metadata.""" + +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo +import hashlib +import re +import sys + +RANDOM_ID = re.compile(rb'(? bytes: + return RANDOM_ID.sub(lambda match: replacements.get(match.group(), match.group()), value) + + +def canonical_id(index: int) -> bytes: + return hashlib.sha256(f'amwebconsent-vehicle-{index}'.encode()).hexdigest()[:32].encode() + + +def normalize(source: Path, destination: Path) -> None: + with ZipFile(source, 'r') as archive: + records = [(archive.getinfo(name), archive.read(name)) for name in archive.namelist()] + + metadata = [] + for original, content in records: + name = original.filename.encode() + if original.filename.endswith(TRANSPORT_METADATA): + metadata.append((RANDOM_ID.sub(b'', name), RANDOM_ID.sub(b'', content), name, content)) + + replacements: dict[bytes, bytes] = {} + for _, _, name, content in sorted(metadata): + for token in RANDOM_ID.findall(name) + RANDOM_ID.findall(content): + if token not in replacements: + replacements[token] = canonical_id(len(replacements)) + + normalized = [] + for original, content in records: + name = replace_ids(original.filename.encode(), replacements).decode() + if original.filename.endswith(TRANSPORT_METADATA): + content = replace_ids(content, replacements) + normalized.append((name, original, content)) + + with ZipFile(destination, 'w', compression=ZIP_DEFLATED, compresslevel=9) as output: + for name, original, content in sorted(normalized, key=lambda record: record[0]): + info = ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.compress_type = ZIP_DEFLATED + info.create_system = 3 + info.external_attr = original.external_attr + info.flag_bits = original.flag_bits + output.writestr(info, content) + + +if __name__ == '__main__': + if len(sys.argv) != 3: + raise SystemExit('usage: normalize-transport.py SOURCE DESTINATION') + normalize(Path(sys.argv[1]), Path(sys.argv[2])) diff --git a/tests/e2e/consent.spec.js b/tests/e2e/consent.spec.js new file mode 100644 index 0000000..12a9ab2 --- /dev/null +++ b/tests/e2e/consent.spec.js @@ -0,0 +1,60 @@ +const { expect, test } = require('@playwright/test'); + +test('does not request a blocked tracker before consent', async ({ page }) => { + const requests = []; + page.on('request', request => { + if (request.url().startsWith('https://tracker.invalid/')) { + requests.push(request.url()); + } + }); + + await page.goto('/tests/fixtures/consent.html'); + await page.waitForFunction(() => window.klaro && window.klaro.getManager()); + await page.waitForTimeout(250); + expect(requests).toEqual([]); + + await page.evaluate(() => { + const manager = window.klaro.getManager(); + manager.changeAll(true); + manager.saveAndApplyConsents('playwright'); + }); + + await expect.poll(() => requests.length).toBe(1); +}); + +test('keeps a blocked tracker disabled after rejection', async ({ page }) => { + const requests = []; + page.on('request', request => { + if (request.url().startsWith('https://tracker.invalid/')) { + requests.push(request.url()); + } + }); + + await page.goto('/tests/fixtures/consent.html'); + await page.waitForFunction(() => window.klaro && window.klaro.getManager()); + await page.evaluate(() => { + const manager = window.klaro.getManager(); + manager.changeAll(false); + manager.saveAndApplyConsents('playwright'); + }); + await page.waitForTimeout(250); + + expect(requests).toEqual([]); +}); + +test('works under a nonce-based strict CSP without inline handlers', async ({ page }) => { + const violations = []; + await page.exposeFunction('recordCspViolation', violation => violations.push(violation)); + await page.addInitScript(() => { + document.addEventListener('securitypolicyviolation', event => { + window.recordCspViolation({blockedURI: event.blockedURI, directive: event.violatedDirective}); + }); + }); + + await page.goto('/tests/fixtures/csp.html'); + await page.waitForFunction(() => window.klaro && window.klaro.getManager()); + expect(await page.locator('[data-amweb-consent-open]').getAttribute('onclick')).toBeNull(); + await page.locator('[data-amweb-consent-open]').click(); + await expect(page.locator('#klaro')).toBeVisible(); + expect(violations).toEqual([]); +}); diff --git a/tests/fixtures/consent.html b/tests/fixtures/consent.html new file mode 100644 index 0000000..be95395 --- /dev/null +++ b/tests/fixtures/consent.html @@ -0,0 +1,18 @@ + + + + + amWeb Consent request blocking test + + + + + + diff --git a/tests/fixtures/csp.html b/tests/fixtures/csp.html new file mode 100644 index 0000000..712238d --- /dev/null +++ b/tests/fixtures/csp.html @@ -0,0 +1,20 @@ + + + + + amWeb Consent CSP test + + + + + + Settings + + diff --git a/tests/fixtures/modx-config.xml b/tests/fixtures/modx-config.xml new file mode 100644 index 0000000..091e9e6 --- /dev/null +++ b/tests/fixtures/modx-config.xml @@ -0,0 +1,27 @@ + + mysql + 127.0.0.1 + modx + root + root + utf8mb4 + utf8mb4 + utf8mb4_unicode_ci + modx_ + 443 + localhost + 1 + 1 + en + ci-admin + ci-only-not-a-secret + ci@example.invalid + /tmp/modx/core/ + /tmp/modx/manager/ + /manager/ + /tmp/modx/connectors/ + /connectors/ + /tmp/modx/ + / + 0 + diff --git a/tests/integration/package-lifecycle.php b/tests/integration/package-lifecycle.php new file mode 100644 index 0000000..8b67b0a --- /dev/null +++ b/tests/integration/package-lifecycle.php @@ -0,0 +1,74 @@ +initialize('mgr'); +$modx->setLogTarget('ECHO'); + +$workspace = $modx->getObject(modWorkspace::class, 1); +if (!$workspace) { + throw new RuntimeException('Default MODX workspace is missing.'); +} + +$package = $modx->newObject(modTransportPackage::class); +$package->fromArray([ + 'signature' => $signature, + 'state' => 1, + 'workspace' => 1, + 'package_name' => 'amwebconsent', + 'version_major' => 1, + 'version_minor' => 0, + 'version_patch' => 0, + 'release' => 'rc', + 'release_index' => 2, + 'created' => date('Y-m-d H:i:s'), +], '', true, true); + +if (!$package->save() || !$package->install()) { + throw new RuntimeException('Transport package installation failed.'); +} + +foreach (['amWebConsent', 'amWebConsentLink', 'amWebConsentJson'] as $name) { + if (!$modx->getObject(modSnippet::class, ['name' => $name])) { + throw new RuntimeException('Missing installed snippet: ' . $name); + } +} +if (!$modx->getObject(modChunk::class, ['name' => 'amWebConsentConfig'])) { + throw new RuntimeException('Missing installed configuration chunk.'); +} +if ($modx->getObject(modSystemSetting::class, ['key' => 'amwebconsent.core_path'])) { + throw new RuntimeException('Unsafe core_path setting must not be installed.'); +} + +if (!$package->uninstall()) { + throw new RuntimeException('Transport package uninstall failed.'); +} + +echo "Package install and uninstall succeeded.\n"; diff --git a/tests/server.mjs b/tests/server.mjs new file mode 100644 index 0000000..9de6c0f --- /dev/null +++ b/tests/server.mjs @@ -0,0 +1,45 @@ +import { createReadStream, statSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { extname, join, normalize } from 'node:path'; + +const root = process.cwd(); +const contentTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', +}; + +createServer((request, response) => { + const pathname = new URL(request.url, 'http://127.0.0.1').pathname; + if (pathname === '/health') { + response.writeHead(204); + response.end(); + return; + } + + const relativePath = normalize(pathname).replace(/^[/\\]+/, ''); + const filePath = join(root, relativePath); + if (!filePath.startsWith(root) || !statIsFile(filePath)) { + response.writeHead(404); + response.end('Not found'); + return; + } + + if (pathname.endsWith('/csp.html')) { + response.setHeader( + 'Content-Security-Policy', + "default-src 'none'; script-src 'self' 'nonce-test-nonce'; style-src 'self'; connect-src 'self' https://tracker.invalid; img-src 'self'; base-uri 'none'; frame-ancestors 'none'" + ); + } + + response.setHeader('Content-Type', contentTypes[extname(filePath)] || 'application/octet-stream'); + createReadStream(filePath).pipe(response); +}).listen(4173, '127.0.0.1'); + +function statIsFile(filePath) { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} From abdfe8832d95fc52427e28f842372f56d6df4b96 Mon Sep 17 00:00:00 2001 From: FlipFlop1974 Date: Wed, 5 Aug 2026 12:27:52 +0200 Subject: [PATCH 2/8] ci: run release gate on pull requests --- .github/workflows/release.yml | 1 + .planning/STATE.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11b08a2..09dc5bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,6 +2,7 @@ name: Build release candidate on: workflow_dispatch: + pull_request: push: tags: ['v*'] diff --git a/.planning/STATE.md b/.planning/STATE.md index b951558..7811a8a 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,7 +4,7 @@ - Active phase: 2 — Security hardening - Active branch: `codex/security-hardening-rc2` - Last verified: 2026-08-05 -- Next action: push a draft PR and let the clean MODX package-lifecycle job run. +- Next action: pass the draft PR's clean MODX package-lifecycle job. ## Verified locally From e406b4722f4516087dddfef1b2907c1005328250 Mon Sep 17 00:00:00 2001 From: FlipFlop1974 Date: Wed, 5 Aug 2026 12:30:02 +0200 Subject: [PATCH 3/8] fix: bootstrap MODX lifecycle test --- tests/integration/package-lifecycle.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/integration/package-lifecycle.php b/tests/integration/package-lifecycle.php index 8b67b0a..887fa29 100644 --- a/tests/integration/package-lifecycle.php +++ b/tests/integration/package-lifecycle.php @@ -13,6 +13,13 @@ throw new RuntimeException('MODX_BASE_PATH is required.'); } define('MODX_BASE_PATH', rtrim($basePath, '/\\') . DIRECTORY_SEPARATOR); +define('MODX_BASE_URL', '/'); +define('MODX_MANAGER_PATH', MODX_BASE_PATH . 'manager/'); +define('MODX_MANAGER_URL', MODX_BASE_URL . 'manager/'); +define('MODX_CONNECTORS_PATH', MODX_BASE_PATH . 'connectors/'); +define('MODX_CONNECTORS_URL', MODX_BASE_URL . 'connectors/'); +define('MODX_ASSETS_PATH', MODX_BASE_PATH . 'assets/'); +define('MODX_ASSETS_URL', MODX_BASE_URL . 'assets/'); if ($argc !== 2 || !is_file($argv[1])) { fwrite(STDERR, "usage: php package-lifecycle.php PACKAGE.transport.zip\n"); From 16906556483f9aa804c2805e95a9fd503c58d3ca Mon Sep 17 00:00:00 2001 From: FlipFlop1974 Date: Wed, 5 Aug 2026 12:31:44 +0200 Subject: [PATCH 4/8] fix: register lifecycle package source --- tests/integration/package-lifecycle.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/integration/package-lifecycle.php b/tests/integration/package-lifecycle.php index 887fa29..19e9fb3 100644 --- a/tests/integration/package-lifecycle.php +++ b/tests/integration/package-lifecycle.php @@ -47,6 +47,7 @@ $package = $modx->newObject(modTransportPackage::class); $package->fromArray([ 'signature' => $signature, + 'source' => $signature . '.transport.zip', 'state' => 1, 'workspace' => 1, 'package_name' => 'amwebconsent', From 222bee2503e89e3985811f0061344d04e390c63a Mon Sep 17 00:00:00 2001 From: FlipFlop1974 Date: Wed, 5 Aug 2026 12:33:28 +0200 Subject: [PATCH 5/8] test: assert installed config chunk name --- tests/integration/package-lifecycle.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/package-lifecycle.php b/tests/integration/package-lifecycle.php index 19e9fb3..f6678cd 100644 --- a/tests/integration/package-lifecycle.php +++ b/tests/integration/package-lifecycle.php @@ -68,7 +68,7 @@ throw new RuntimeException('Missing installed snippet: ' . $name); } } -if (!$modx->getObject(modChunk::class, ['name' => 'amWebConsentConfig'])) { +if (!$modx->getObject(modChunk::class, ['name' => 'amwebconsent.config'])) { throw new RuntimeException('Missing installed configuration chunk.'); } if ($modx->getObject(modSystemSetting::class, ['key' => 'amwebconsent.core_path'])) { From 89cb1d0f6e4b0b7a5a36c6e030a7709b21337b78 Mon Sep 17 00:00:00 2001 From: FlipFlop1974 Date: Wed, 5 Aug 2026 12:35:18 +0200 Subject: [PATCH 6/8] docs: record passing release gate --- .planning/REQUIREMENTS.md | 2 +- .planning/STATE.md | 3 ++- .planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md | 2 +- .../quick/260805-sec-security-hardening-rc2/VERIFICATION.md | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 993b950..22e2025 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -7,6 +7,6 @@ - [x] SEC-03 Reject unsupported MODX/PHP versions during installation. - [x] SEC-04 Prove in a browser that blocked services make no request before consent. - [x] SEC-05 Pin CI actions and produce a normalized, reproducible transport package. -- [ ] REL-01 Pass the clean MODX install/uninstall job on GitHub Actions. +- [x] REL-01 Pass the clean MODX install/uninstall job on GitHub Actions. - [ ] REL-02 Review and merge the hardening pull request. - [ ] REL-03 Publish and verify the `1.0.0-rc2` prerelease asset and checksum. diff --git a/.planning/STATE.md b/.planning/STATE.md index 7811a8a..df83026 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,7 +4,7 @@ - Active phase: 2 — Security hardening - Active branch: `codex/security-hardening-rc2` - Last verified: 2026-08-05 -- Next action: pass the draft PR's clean MODX package-lifecycle job. +- Next action: review and merge draft PR #1, then publish `1.0.0-rc2`. ## Verified locally @@ -13,6 +13,7 @@ - PHP syntax: passed in the MODX DDEV runtime - MODX transport build: passed twice - Normalized artifacts: byte-identical +- Clean MODX 3.2.3 install/uninstall: passed in GitHub Actions run 30997904144 - Current artifact SHA-256: `fea322a43937f6945efd908a881463fac199a2aca2eede188ca849c2d1f321a9` ## Decisions diff --git a/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md b/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md index 2d8fff5..0d1940e 100644 --- a/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md +++ b/.planning/quick/260805-sec-security-hardening-rc2/SUMMARY.md @@ -11,4 +11,4 @@ All five requested hardening areas were implemented for `1.0.0-rc2`. - MODX-generated random vehicle IDs, ZIP timestamps, metadata, and ordering are normalized. - The release workflow builds twice, compares artifacts, tests clean install/uninstall, uploads a checksum, and emits provenance for tag builds. -The only remaining validation is the GitHub-hosted clean MODX lifecycle run before merge/release. +The GitHub-hosted clean MODX lifecycle run passed. Only review/merge and publishing the release candidate remain. diff --git a/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md b/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md index 28633dd..3ce0e8a 100644 --- a/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md +++ b/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md @@ -1,6 +1,6 @@ # Verification -Status: locally passed; hosted lifecycle gate pending. +Status: passed locally and in the hosted lifecycle gate. | Gate | Result | |---|---| @@ -10,6 +10,6 @@ Status: locally passed; hosted lifecycle gate pending. | PHP syntax | PASS | | MODX package build | PASS | | Reproducible normalized ZIP | PASS | -| Clean MODX install/uninstall | PENDING — GitHub Actions | +| Clean MODX install/uninstall | PASS — GitHub Actions run 30997904144 | Artifact SHA-256: `fea322a43937f6945efd908a881463fac199a2aca2eede188ca849c2d1f321a9` From 4bf5157622a28ffb00f84fb09c7a5713c7dc2646 Mon Sep 17 00:00:00 2001 From: FlipFlop1974 Date: Wed, 5 Aug 2026 13:49:52 +0200 Subject: [PATCH 7/8] fix: set friendly package display name --- CHANGELOG.md | 1 + _build/build.config.sample.php | 1 + _build/build.transport.php | 3 +++ .../resolve.package-display-name.php | 22 +++++++++++++++++++ tests/integration/package-lifecycle.php | 7 ++++++ 5 files changed, 34 insertions(+) create mode 100644 _build/resolvers/resolve.package-display-name.php diff --git a/CHANGELOG.md b/CHANGELOG.md index c5ec9a9..acddaeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.0.0-rc2 +- Use `amWeb Consent (powered by Klaro)` as the installed package display name. - Safely JSON-encode dynamic JavaScript values and reCAPTCHA settings. - Remove configurable PHP and asset paths from runtime loading. - Add CSP nonce support and replace inline click handlers. diff --git a/_build/build.config.sample.php b/_build/build.config.sample.php index 7186c90..b477289 100644 --- a/_build/build.config.sample.php +++ b/_build/build.config.sample.php @@ -30,6 +30,7 @@ 'assets' => $root . 'assets/components/amwebconsent/', 'data' => __DIR__ . '/data/', 'validators' => __DIR__ . '/validators/', + 'resolvers' => __DIR__ . '/resolvers/', 'snippets' => $root . 'core/components/amwebconsent/elements/snippets/', 'chunks' => $root . 'core/components/amwebconsent/elements/chunks/', ]; diff --git a/_build/build.transport.php b/_build/build.transport.php index 6ab157e..0670e39 100644 --- a/_build/build.transport.php +++ b/_build/build.transport.php @@ -102,6 +102,9 @@ function amwebConsentReadSnippet(string $path): string $vehicle->validate('php', [ 'source' => $sources['validators'] . 'validate.requirements.php', ]); +$vehicle->resolve('php', [ + 'source' => $sources['resolvers'] . 'resolve.package-display-name.php', +]); $builder->putVehicle($vehicle); diff --git a/_build/resolvers/resolve.package-display-name.php b/_build/resolvers/resolve.package-display-name.php new file mode 100644 index 0000000..4b26158 --- /dev/null +++ b/_build/resolvers/resolve.package-display-name.php @@ -0,0 +1,22 @@ +xpdo->getObject(modTransportPackage::class, [ + 'signature' => 'amwebconsent-1.0.0-rc2', +]); + +if ($package) { + $package->set('package_name', 'amWeb Consent (powered by Klaro)'); + return $package->save(); +} + +return true; diff --git a/tests/integration/package-lifecycle.php b/tests/integration/package-lifecycle.php index f6678cd..c662ee5 100644 --- a/tests/integration/package-lifecycle.php +++ b/tests/integration/package-lifecycle.php @@ -71,6 +71,13 @@ if (!$modx->getObject(modChunk::class, ['name' => 'amwebconsent.config'])) { throw new RuntimeException('Missing installed configuration chunk.'); } +$displayName = $modx->query( + "SELECT package_name FROM {$modx->getTableName(modTransportPackage::class)} WHERE signature = " + . $modx->quote($signature) +)->fetchColumn(); +if ($displayName !== 'amWeb Consent (powered by Klaro)') { + throw new RuntimeException('Package display name was not updated.'); +} if ($modx->getObject(modSystemSetting::class, ['key' => 'amwebconsent.core_path'])) { throw new RuntimeException('Unsafe core_path setting must not be installed.'); } From 7b0224a01ef519f8e30b17370ed53ee67084d7ee Mon Sep 17 00:00:00 2001 From: FlipFlop1974 Date: Wed, 5 Aug 2026 14:12:01 +0200 Subject: [PATCH 8/8] fix: render valid consent configuration script --- CHANGELOG.md | 1 + .../amwebconsent/model/amwebconsent/amwebconsent.class.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acddaeb..131d571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.0.0-rc2 +- Fix the generated configuration script so it contains real newlines instead of invalid literal `\\n` tokens. - Use `amWeb Consent (powered by Klaro)` as the installed package display name. - Safely JSON-encode dynamic JavaScript values and reCAPTCHA settings. - Remove configurable PHP and asset paths from runtime loading. diff --git a/core/components/amwebconsent/model/amwebconsent/amwebconsent.class.php b/core/components/amwebconsent/model/amwebconsent/amwebconsent.class.php index 2c04b71..2ec7aa0 100644 --- a/core/components/amwebconsent/model/amwebconsent/amwebconsent.class.php +++ b/core/components/amwebconsent/model/amwebconsent/amwebconsent.class.php @@ -81,7 +81,7 @@ public function render(array $properties = []) if ($config === '') { $this->modx->log(modX::LOG_LEVEL_ERROR, '[amWeb Consent] Config chunk "' . $configChunk . '" returned empty output.'); } else { - $output[] = '\n' . $config . '\n'; + $output[] = '' . "\n" . $config . "\n" . ''; } }