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..09dc5bd
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,67 @@
+name: Build release candidate
+
+on:
+ workflow_dispatch:
+ pull_request:
+ 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..22e2025
--- /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.
+- [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/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..df83026
--- /dev/null
+++ b/.planning/STATE.md
@@ -0,0 +1,25 @@
+# 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: review and merge draft PR #1, then publish `1.0.0-rc2`.
+
+## 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
+- Clean MODX 3.2.3 install/uninstall: passed in GitHub Actions run 30997904144
+- 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..0d1940e
--- /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 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
new file mode 100644
index 0000000..3ce0e8a
--- /dev/null
+++ b/.planning/quick/260805-sec-security-hardening-rc2/VERIFICATION.md
@@ -0,0 +1,15 @@
+# Verification
+
+Status: passed locally and in the hosted lifecycle gate.
+
+| 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 | PASS — GitHub Actions run 30997904144 |
+
+Artifact SHA-256: `fea322a43937f6945efd908a881463fac199a2aca2eede188ca849c2d1f321a9`
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3dba27..131d571 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## 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.
+- 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", ''],
- ["\\\\", "\\'", '\\r', '\\n', '<\\/'],
- (string) $value
+ $encoded = json_encode(
+ (string) $value,
+ JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES
);
+
+ return $encoded === false ? '""' : $encoded;
}
}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..9af5927
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,78 @@
+{
+ "name": "amweb-consent",
+ "version": "1.0.0-rc.2",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "amweb-consent",
+ "version": "1.0.0-rc.2",
+ "devDependencies": {
+ "@playwright/test": "1.62.1"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=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..c662ee5
--- /dev/null
+++ b/tests/integration/package-lifecycle.php
@@ -0,0 +1,89 @@
+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,
+ 'source' => $signature . '.transport.zip',
+ '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' => '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.');
+}
+
+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;
+ }
+}