Skip to content
Merged
9 changes: 8 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,21 @@ jobs:
token=$(printf '%s' "$fixture" | jq -r .token)
docker run --rm --network php2shell-ci_fixture -v "$PWD:/app:ro" -w /app -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/app -e CT_FIXTURE_TOKEN="$token" python:3.12-slim-bookworm@sha256:6a0e06f5ba0b373d051aabd3fffb9b873f27f9a2a3a14bcbe37dee6f0e3bbc70 python deployment/test-http-worker.py
docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-http-fixture.php cleanup
- name: Test target control (challenge limits, manual review, blocked outcome) and its HTTP boundary
run: |
docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/control.php
fixture=$(docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-http-fixture.php setup control)
token=$(printf '%s' "$fixture" | jq -r .token)
docker run --rm --network php2shell-ci_fixture -v "$PWD:/app:ro" -w /app -e PYTHONDONTWRITEBYTECODE=1 -e PYTHONPATH=/app -e CT_FIXTURE_TOKEN="$token" python:3.12-slim-bookworm@sha256:6a0e06f5ba0b373d051aabd3fffb9b873f27f9a2a3a14bcbe37dee6f0e3bbc70 python deployment/test-http-control.py
docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-http-fixture.php cleanup
- name: Test worker under Linux container privileges
run: >-
docker run --rm --network none --read-only --cap-drop ALL --cap-add SETUID --cap-add SETGID --cap-add KILL
--security-opt no-new-privileges --pids-limit 32 --memory 384m
--tmpfs /tmp:rw,noexec,nosuid,size=32m,mode=1777
-v "$PWD:/app:ro" -w /app -e PYTHONDONTWRITEBYTECODE=1
python:3.12-slim-bookworm@sha256:6a0e06f5ba0b373d051aabd3fffb9b873f27f9a2a3a14bcbe37dee6f0e3bbc70
python -m unittest -v test_scan_worker test_admission
python -m unittest -v test_scan_worker test_admission test_control
- name: Build and check packaged isolation
run: |
docker build --build-arg CHECKER_REVISION="$GITHUB_SHA" -f deployment/Dockerfile -t ct-worker-fixture .
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ shared development instructions; [CLAUDE.md](CLAUDE.md) points Claude to them.
- [WordPress free-scan integration](docs/WORDPRESS.md): signed requests, confirmation,
private job queue, restricted worker, deployment and recovery. **Activation
requires enforced network egress** (`egress_enforced`); the repository's
Compose file does not provide it.
Compose file does not provide it. It also requires an explicit `terms_policy`
entry for the served terms version; without one the integration stays
disabled (503) and in-flight jobs stall.
- [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)

```sh
Expand Down
118 changes: 118 additions & 0 deletions deployment/test-http-control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Real HTTP test of the exact-target control gate against the internal disposable WP fixture only.

Run after `test-http-fixture.php setup control`. The customer's pages are driven over HTTP; the
worker side uses the real signed API client. The challenge value is supplied by this test (no
DNS lookup or website request is made): it exercises the WordPress boundary, not a target.
"""
import http.client
import os
import re
from urllib.parse import urlencode

from integrations.worker.admission import AdmissionGate
from integrations.worker.protocol import APIClient, APIError

HOST = os.environ.get('CT_FIXTURE_HOST', 'wordpress')
PORT = int(os.environ.get('CT_FIXTURE_PORT', '80'))


class Response:
def __init__(self, response, connection):
self.response, self.connection, self.code = response, connection, response.status

def read(self, limit):
return self.response.read(limit)

def __enter__(self):
return self

def __exit__(self, *_):
self.connection.close()


class Transport:
def open(self, request, timeout):
from urllib.parse import urlsplit
route = urlsplit(request.full_url).path.removeprefix('/wp-json')
connection = http.client.HTTPConnection(HOST, PORT, timeout=timeout)
connection.request('POST', '/?rest_route=' + route, body=request.data, headers=dict(request.header_items()))
return Response(connection.getresponse(), connection)


def page(method, path, body=None):
connection = http.client.HTTPConnection(HOST, PORT, timeout=10)
headers = {'Content-Type': 'application/x-www-form-urlencoded'} if body is not None else {}
connection.request(method, path, body, headers)
response = connection.getresponse()
text = response.read().decode()
connection.close()
return response.status, text


def hidden(text):
return dict(re.findall(r'name="([^"]+)" value="([^"]*)"', text))


api = APIClient(b'fixture-' * 8, transport=Transport())
token = os.environ['CT_FIXTURE_TOKEN']
assert re.fullmatch('[a-f0-9]{64}', token)

# 1. Mailbox confirmation does not queue: the job waits for control of the website.
status, text = page('GET', '/wp-admin/admin-post.php?action=ct_scan_confirm&token=' + token)
assert status == 200
status, text = page('POST', '/wp-admin/admin-post.php', urlencode(hidden(text)))
assert status == 200 and 'Show control of your website' in text, text[:300]
challenge = re.search(r'ct-scan-verify=([a-f0-9]{64})', text).group(1)
form = hidden(text)
assert form['ctl'] != challenge and form['job'] and form['_wpnonce'], 'The page capability must not be the published challenge'
assert api.claim() is None, 'A job awaiting target control was claimable for scanning'
assert api.control_claim() is None, 'A check ran before the customer asked for one'

# 2. The customer asks for a DNS check; a second request is refused while it is outstanding.
status, text = page('POST', '/wp-admin/admin-post.php', urlencode(dict(form, method='dns_txt')))
assert status == 200 and 'scheduled' in text, text[:300]
status, text = page('POST', '/wp-admin/admin-post.php', urlencode(dict(form, method='https_file')))
assert 'already scheduled' in text, text[:300]
status, _ = page('POST', '/wp-admin/admin-post.php', urlencode(dict(form, method='dns_txt', ctl='f' * 64)))
assert status == 410, 'A wrong page capability reached the control handler'
status, _ = page('POST', '/wp-admin/admin-post.php', urlencode(dict(form, method='dns_txt', ctl=challenge)))
assert status == 410, 'The published challenge value opened the control page'
status, _ = page('POST', '/wp-admin/admin-post.php', urlencode({k: v for k, v in dict(form, method='dns_txt').items() if k != '_wpnonce'}))
assert status == 403, 'A control request without its nonce was accepted'

# 3. The worker claims it, is admitted exactly once, and reports what it saw.
task = api.control_claim()
assert task and task.method == 'dns_txt' and task.target == 'https://http-fixture.example.com/'
assert api.control_claim() is None, 'A checking job was claimed twice'
gate = AdmissionGate(api, task, kind='control')
grant = gate.admit()
assert (grant.sequence, grant.remaining) == (1, 2)
try:
api.control_result(task, 'observed', ['ct-scan-verify=' + 'a' * 64])
verified = None
except APIError as error:
verified = error
assert verified is None # a wrong value is a recorded failure, not an error
assert api.claim() is None, 'A wrong challenge value released the job'
text = page('GET', '/wp-admin/admin-post.php?' + urlencode({'action': 'ct_scan_control', 'job': form['job'], 'ctl': form['ctl']}))[1]
assert 'ct-scan-verify=' + challenge in text, 'The returning customer is shown the same challenge'
assert 'Checks used: 1 of 3' in text, text[:400]

# 4. One minute later the customer asks again; the right value releases the job for scanning.
age = os.environ.get('CT_FIXTURE_AGE_COMMAND')
if age: # CI ages the fixture row by a minute instead of sleeping; local runs may wait
import subprocess
subprocess.run(age, shell=True, check=True)
else:
import time
time.sleep(61)
fresh = hidden(text)
status, text = page('POST', '/wp-admin/admin-post.php', urlencode(dict(fresh, method='dns_txt')))
assert 'scheduled' in text, text[:300]
task = api.control_claim()
assert task, 'The second check was not scheduled'
AdmissionGate(api, task, kind='control').admit()
assert api.control_result(task, 'observed', ['ct-scan-verify=' + challenge]) == {'ok': True, 'verified': True}
claim = api.claim()
assert claim and claim.target == 'https://http-fixture.example.com/', 'A verified job was not claimable'
print('HTTP target-control gate passed; no DNS lookup or website request was made.')
22 changes: 19 additions & 3 deletions deployment/test-http-fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
file_put_contents($private . '/config.php', '<?php return ' . var_export(array(
'enabled' => true,
'egress_enforced' => true, // disposable fixture: no network beyond the test network
'terms_policy' => array('2026-10-21-r2' => 'legacy'),
// `setup control` runs the strict policy: bound evidence and proof of control of the exact target.
'terms_policy' => array('2026-10-21-r2' => ($argv[2] ?? '') === 'control' ? 'strict' : 'legacy'),
'worker_hmac_secret' => str_repeat('fixture-', 8),
'expected_checker_revision' => str_repeat('a', 40),
'authorization_end_at' => gmdate('Y-m-d\TH:i:s\Z', time() + 86400 * 30),
Expand All @@ -35,9 +36,20 @@
$id = wp_insert_post(array('post_type' => 'ct_promo_request', 'post_status' => 'private', 'post_title' => 'HTTP fixture', 'post_content' => $contract));
foreach (array('target', 'email', 'name') as $key) { update_post_meta($id, '_ct_scan_' . $key, $fields[$key]); }
update_post_meta($id, '_ct_ref', 'direct');
foreach (array('saved' => '1', 'version' => '2026-10-21-r2', 'url' => 'http://wordpress/terms/', 'sha256' => hash('sha256', $contract), 'accepted_at' => gmdate('Y-m-d\TH:i:s\Z'), 'contract' => $contract, 'ip' => '192.0.2.1', 'remote_addr' => '192.0.2.1', 'user_agent' => 'Local HTTP fixture') as $key => $value) {
$strict = ($argv[2] ?? '') === 'control';
$snapshot = "<p>Fixture terms, exact bytes.</p>\n";
foreach (array('saved' => '1', 'version' => '2026-10-21-r2', 'url' => 'http://wordpress/terms/', 'sha256' => hash('sha256', $strict ? $snapshot : $contract), 'accepted_at' => gmdate('Y-m-d\TH:i:s\Z'), 'contract' => $contract, 'ip' => '192.0.2.1', 'remote_addr' => '192.0.2.1', 'user_agent' => 'Local HTTP fixture') as $key => $value) {
update_post_meta($id, '_ct_terms_' . $key, wp_slash($value));
}
if ($strict) {
// Strict policy: the acceptance is bound to the exact terms and Target Summary the signer was shown.
$summary = ct_scan_target_summary($fields['target'], $fields['name'], $fields['email']);
foreach (array('binding' => '1', 'snapshot' => $snapshot) as $key => $value) { update_post_meta($id, '_ct_terms_' . $key, wp_slash($value)); }
update_post_meta($id, '_ct_terms_shown_digest', hash('sha256', $snapshot));
update_post_meta($id, '_ct_summary_text', wp_slash($summary));
update_post_meta($id, '_ct_summary_sha256', hash('sha256', $summary));
update_post_meta($id, '_ct_summary_shown_digest', hash('sha256', $summary));
}
$token = '';
add_filter('pre_wp_mail', static function ($return, $mail) use (&$token) {
if (preg_match('/token=([a-f0-9]{64})/', $mail['message'], $match)) { $token = $match[1]; }
Expand All @@ -51,7 +63,11 @@
} else {
$state = json_decode(file_get_contents($state_file), true);
$row = ct_scan_row($state['job_id']);
if ('cleanup' === $mode) {
if ('age-control' === $mode) {
// Test-only: pretend the last control check happened a minute ago.
$wpdb->query($wpdb->prepare('UPDATE ' . ct_scan_jobs_table() . ' SET control_last_attempt_ms = control_last_attempt_ms - 61000 WHERE job_id = %d', $state['job_id']));
echo "aged\n";
} elseif ('cleanup' === $mode) {
$wpdb->delete(ct_scan_jobs_table(), array('job_id' => $state['job_id']));
wp_delete_post($state['request_id'], true);
unlink($state_file);
Expand Down
Loading
Loading