diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 1f192c3..1fac82a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -57,6 +57,13 @@ 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
@@ -64,7 +71,7 @@ jobs:
--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 .
diff --git a/README.md b/README.md
index 68c3b68..808e03e 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/deployment/test-http-control.py b/deployment/test-http-control.py
new file mode 100644
index 0000000..8ccead7
--- /dev/null
+++ b/deployment/test-http-control.py
@@ -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.')
diff --git a/deployment/test-http-fixture.php b/deployment/test-http-fixture.php
index 58ba4f0..09e5490 100644
--- a/deployment/test-http-fixture.php
+++ b/deployment/test-http-fixture.php
@@ -11,7 +11,8 @@
file_put_contents($private . '/config.php', ' 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),
@@ -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 = "
Fixture terms, exact bytes.
\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]; }
@@ -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);
diff --git a/docs/WORDPRESS.md b/docs/WORDPRESS.md
index 0499c10..e120775 100644
--- a/docs/WORDPRESS.md
+++ b/docs/WORDPRESS.md
@@ -58,8 +58,8 @@ flowchart LR
## Worker protocol
-The four POST routes are `claim`, `start`, `admit` and `result`, under
-`/wp-json/ct-scan/v1/`.
+The POST routes are `claim`, `start`, `admit`, `result`, `control/claim` and
+`control/result`, under `/wp-json/ct-scan/v1/`.
Each request carries `X-CT-Timestamp` (Unix seconds), `X-CT-Nonce` (32 lowercase
hex characters), and `X-CT-Signature` (HMAC-SHA256 hex). The signed string is the
timestamp, nonce, `POST`, route without `/wp-json`, and raw-body SHA-256 joined
@@ -97,7 +97,8 @@ restart cannot create a fresh budget. A grant is consumed when issued, is
valid for one second and covers one connection and one request. `probe` only
reports whether the job is still live; it takes no budget and has no pacing.
Refusals use `410` (permanent: cancelled, expired, revoked, lease, profile,
-unconfirmed, clock anomaly), `409` (`budget_exhausted`), `429` (`rate_wait`
+unconfirmed, clock anomaly), `409` (`budget_exhausted`; `already_admitted` for a
+control lease that was already admitted), `429` (`rate_wait`
with `retry_after_ms`, bounded retries) and `503` (`queue_busy`, the lock is
held elsewhere). A `503` for a disabled integration or a schema mismatch also
refuses. Nothing is admitted unless WordPress says so.
@@ -175,6 +176,87 @@ Before upgrading the pinned revision or rotating the key, stop accepting new
work, drain/reconcile existing receipts and stop the controller. Do not change
the expected revision while old receipts are awaiting acknowledgement.
+## Exact-target control
+
+Mailbox confirmation shows control of an email address, not of the website, and
+a referral code shows neither. **Every job must show control of the exact
+hostname before any scan can be claimed, unless its terms version was explicitly
+grandfathered**: the `strict` policy (the default for every version; see "Strict
+by default" above) requires it, and only an r2-or-earlier version listed as
+`legacy` in `terms_policy` is exempt. Each job freezes that requirement when it
+is created.
+For such a job, confirmation no longer queues it: it waits as
+`awaiting_control`, and the confirmation page shows a one-time challenge token
+(only its hash is stored) with two ways to publish it. The published challenge
+value and the customer's page capability are two independent secrets: the page
+address (which contains the capability, and is the only way back to the page)
+is never published, and the challenge shown on it is derived from the capability
+with a keyed hash, so someone who reads the published challenge cannot open the
+page, request checks or use up the three attempts:
+
+- a DNS TXT record at `_ct-scan-challenge.` with the value
+ `ct-scan-verify=`, or
+- the file `https:///.well-known/ct-scan-challenge.txt` whose entire
+ content is the token (surrounding whitespace and a leading UTF-8 byte-order
+ mark are ignored).
+
+The TXT lookup asks for TXT records at exactly that name: a CNAME there is not
+followed, so a TXT record delegated through a CNAME (as some DNS providers do)
+does not verify. Resolvers cache a "no such record" answer for the zone's
+negative-caching time, so a check requested before a new record has propagated
+can see no record, and it still uses one of the three checks; the customer page
+says to wait until the record is publicly visible.
+
+The customer's page (address shown once; GET only displays it, a nonce-protected
+POST acts) lets them request a check. The disclosed limits: at most three checks
+per job, at least a minute apart; each check is one TXT lookup or one HTTPS
+request to the fixed path, with no redirects, at most 4096 bytes and a ten-second
+timeout; no probe of any kind is sent to help a failed check pass. A scheduled
+check is taken by the worker (`control/claim`), admitted exactly once through the
+same guard as a scan (`admit` with `kind` `control`; the attempt is consumed in the
+admission's compare-and-set, with its own pacing, budget, cancellation, expiry and
+lease checks, and `control_probe` for liveness). WordPress records the admission
+on the lease (`control_admitted`), so a second `admit` under the same lease is
+refused with `409` `already_admitted` for the rest of the two-minute lease, even
+after the one-minute interval has passed, and the worker ends the check without
+retrying. The check is run inside the unprivileged
+child, and reported with `control/result`. The worker relays what it observed
+(untrusted, length-bounded); WordPress compares it with the stored hash. A result
+is accepted only if an admission happened under that lease, so no check can
+count without having been admitted. A match releases the job (`queued`,
+`control_method`, `control_verified_at`, evidence limited to the method, attempt,
+time, target and a hash of the matched value). Three failed checks end the job as
+`blocked` (`target_control_failed`); a blocked job is never scanned and gets no
+detailed report. There is no fallback from a failed challenge to a scan.
+
+An administrator can instead record an **authority review** (admin queue,
+`authority_review`): the evidence reviewed (20 to 1000 characters), the target,
+the reviewer's account, the time and the expiry (the authorization expiry) are
+stored, and the job is released the same way. A review is refused while a DNS or
+file check is in flight (`control_state` `checking`); record it once that check
+has reported or its lease has lapsed. An expired review ends the
+authorization. Detailed reports (`ct_scan_admin_send_report`) additionally
+require control of the target for jobs that required it, in any terminal state.
+
+DNS answers are parsed defensively (matching id, question and source address; a
+truncated or malformed answer is an error, with no TCP or resolver fallback) and
+come from the container's first configured resolver; they grant no exemption from
+the connection-safety rules, which still apply to the HTTPS check. Control checks
+and the scan share the single worker loop; no check can start while a scan runs.
+The gate is on by default and cannot be dropped by omission: the integration
+refuses to enable unless the served terms version has an explicit `terms_policy`
+entry, a new version is strict whatever the configuration says, and a profile
+broader than the r2 header screening always requires control.
+The requirement is frozen per job: jobs created before the upgrade to schema 5
+have `control_required = 0` and keep their earlier behaviour; a row whose value
+is missing is treated as requiring control.
+One piece of target traffic runs at a time worker-wide: a scan claim is refused
+while a check is in progress and the reverse, enforced in WordPress. If every
+check is spent but the last result was lost, maintenance blocks the job.
+Not done: reissuing a challenge after `blocked` (a new request is needed),
+per-contact/domain/IP quotas for fresh challenges, and customer-facing wording
+(the pages are functional placeholders marked `TODO(owner)`).
+
## Bound acceptance evidence and the accepted copy
A request is queued only if its saved acceptance reads back completely. A
@@ -227,8 +309,8 @@ confirmation sends the verified contact their copy: the Target Summary, the
hashes, the filled-in copy and, for a bound acceptance, the exact terms bytes
as an attachment (written to a private temporary directory and attached only if
it reproduces the recorded hash). The recipient must still be the confirmed,
-authorized address, and the job must still be live (queued, leased or running,
-not revoked, and its retained evidence unchanged), re-checked under the queue
+authorized address, and the job must still be live (awaiting target control,
+queued, leased or running, not revoked, and its retained evidence unchanged), re-checked under the queue
lock immediately before the send is claimed. It is sent at most once (the status is claimed by
compare-and-set before mail is handed off), only to the verified address, and
never to a referrer. An uncertain or failed hand-off is recorded as `unknown`
@@ -378,8 +460,17 @@ The default configuration is disabled, and no historical request sweep exists.
5. Enforce egress (see "Egress"), then set `egress_enforced` true. Apply the
reviewed site patch/terms update, then set `enabled` true. The runner must
authenticate successfully before accepting live submissions. A matching
- revision and key and `egress_enforced` are required; otherwise the
- integration stays disabled.
+ revision and key, `egress_enforced`, and an explicit `terms_policy` entry
+ (`strict` or `legacy`, see "Bound acceptance evidence") for the served
+ `supported_terms_version` are required; otherwise the integration stays
+ disabled.
+
+**Upgrading an existing deployment:** add the `terms_policy` entry for the
+served terms version before (or together with) installing this plugin
+version. Without it the integration is disabled: the whole worker API answers
+503, new acceptances are not queued, and in-flight (queued, leased or running)
+jobs stall (they are not claimed, admitted or finished) until the entry is
+added or their authorization expires.
Take a database backup first: the upgrade changes the jobs table in place. If it
cannot complete, the failure is written to the PHP error log once, the worker
diff --git a/integrations/wordpress/ct-scan.php b/integrations/wordpress/ct-scan.php
index a1c9ae3..750ae3e 100644
--- a/integrations/wordpress/ct-scan.php
+++ b/integrations/wordpress/ct-scan.php
@@ -17,7 +17,7 @@
}
if ( ! defined( 'CT_SCAN_SCHEMA_VERSION' ) ) {
- define( 'CT_SCAN_SCHEMA_VERSION', '4' );
+ define( 'CT_SCAN_SCHEMA_VERSION', '5' );
}
/**
@@ -32,6 +32,21 @@ function ct_scan_current_profile() {
return 'r2-header-v1';
}
+/** Disclosed limits of the exact-target control check. */
+function ct_scan_control_limits() {
+ return array(
+ 'max_attempts' => 3,
+ 'min_interval_ms' => 60000,
+ 'max_bytes' => 4096,
+ 'timeout_ms' => 10000,
+ 'valid_ms' => 1000,
+ 'lease_seconds' => 120,
+ 'path' => '/.well-known/ct-scan-challenge.txt',
+ 'dns_label' => '_ct-scan-challenge',
+ 'dns_prefix' => 'ct-scan-verify=',
+ );
+}
+
function ct_scan_profile_limits( $profile_id ) {
$profiles = array(
'r2-header-v1' => array(
@@ -68,7 +83,7 @@ function ct_scan_config() {
// Email the verified contact their accepted copy after confirmation (off by default).
'send_accepted_copy' => false,
// An explicit decision for every terms version the integration serves: array( '' => 'strict' | 'legacy' ).
- // `strict` (also what an unlisted version gets) requires bound evidence and, later, proof of control of the
+ // `strict` (also what an unlisted version gets) requires bound evidence and proof of control of the
// exact target. `legacy` grandfathers an r2-or-earlier acceptance flow and is honoured ONLY for those versions.
// The integration refuses to enable unless supported_terms_version has an entry here.
'terms_policy' => array(),
@@ -233,6 +248,21 @@ function ct_scan_maybe_install_schema() {
bound_required tinyint(1) unsigned NOT NULL DEFAULT 0,
accepted_copy_status varchar(16) NOT NULL DEFAULT 'not_sent',
accepted_copy_at datetime NULL,
+ control_required tinyint(1) unsigned NOT NULL DEFAULT 0,
+ control_state varchar(12) NOT NULL DEFAULT 'none',
+ control_token_hash char(64) NULL,
+ control_page_hash char(64) NULL,
+ control_requested_method varchar(12) NULL,
+ control_attempts tinyint(3) unsigned NOT NULL DEFAULT 0,
+ control_last_attempt_ms bigint(20) unsigned NULL,
+ control_leased_ms bigint(20) unsigned NULL,
+ control_admitted tinyint(1) unsigned NOT NULL DEFAULT 0,
+ control_lease_hash char(64) NULL,
+ control_lease_until datetime NULL,
+ control_method varchar(16) NULL,
+ control_verified_at datetime NULL,
+ control_valid_until datetime NULL,
+ control_evidence text NULL,
PRIMARY KEY (job_id),
UNIQUE KEY request_id (request_id),
KEY status_created (status,created_at),
@@ -250,7 +280,7 @@ function ct_scan_maybe_install_schema() {
) {$charset};";
dbDelta( $sql );
$required = array(
- $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at' ),
+ $jobs => array( 'job_id', 'request_id', 'target_origin', 'scope_hash', 'terms_version', 'terms_hash', 'terms_accepted_at', 'authorization_expires_at', 'status', 'lease_hash', 'result_json', 'receipt_hash', 'report_delivered_at', 'stop_token_hash', 'profile_id', 'http_admitted', 'last_admit_ms', 'summary_sha256', 'bound_required', 'accepted_copy_status', 'accepted_copy_at', 'control_required', 'control_state', 'control_token_hash', 'control_page_hash', 'control_requested_method', 'control_attempts', 'control_last_attempt_ms', 'control_leased_ms', 'control_admitted', 'control_lease_hash', 'control_lease_until', 'control_method', 'control_verified_at', 'control_valid_until', 'control_evidence' ),
$nonces => array( 'nonce_hash', 'expires_at' ),
);
foreach ( $required as $table => $columns ) {
@@ -641,8 +671,12 @@ function ct_scan_on_request_ready( $request_id, $fields ) {
'profile_id' => ct_scan_current_profile(),
'summary_sha256' => $accepted['summary_sha256'],
'bound_required' => $accepted['bound_required'] ? 1 : 0,
+ // Frozen at creation: a later configuration change cannot relax an existing job. Every version is strict
+ // (requires control of the exact target) unless an r2-or-earlier version was explicitly grandfathered.
+ // A profile broader than the r2 header screening can never run without target control.
+ 'control_required' => ( 'strict' === ct_scan_terms_policy( $accepted['terms_version'] ) || 'r2-header-v1' !== ct_scan_current_profile() ) ? 1 : 0,
),
- array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' )
+ array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d' )
);
if ( 1 !== $inserted ) {
$existing = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$jobs} WHERE request_id = %d LIMIT 1", $request_id ) );
@@ -702,6 +736,11 @@ function ct_scan_row( $job_id ) {
return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE job_id = %d", absint( $job_id ) ) );
}
+/** Does this job require proof of control of the exact target? A missing value counts as required. */
+function ct_scan_control_required( $row ) {
+ return ! is_object( $row ) || ! isset( $row->control_required ) || ! empty( $row->control_required );
+}
+
function ct_scan_authorization_is_live( $row ) {
if ( ! is_object( $row ) || ! empty( $row->revoked_at ) || ! empty( $row->report_delivered_at )
|| strtotime( $row->authorization_expires_at . ' UTC' ) <= time() ) {
@@ -710,6 +749,13 @@ function ct_scan_authorization_is_live( $row ) {
if ( ct_scan_meta_string( (int) $row->request_id, '_ct_scan_authorization_expires_at' ) !== $row->authorization_expires_at ) {
return false;
}
+ // A job that required proof of control of the exact target is runnable only while that proof stands.
+ if ( ct_scan_control_required( $row ) && in_array( $row->status, array( 'queued', 'leased', 'running' ), true ) ) {
+ if ( empty( $row->control_verified_at )
+ || ( ! empty( $row->control_valid_until ) && strtotime( $row->control_valid_until . ' UTC' ) <= time() ) ) {
+ return false;
+ }
+ }
$fields = array(
'target' => $row->target_origin,
'email' => $row->contact_email,
@@ -737,7 +783,7 @@ function ct_scan_maintenance_locked() {
$table = ct_scan_jobs_table();
$now = ct_scan_utc_now();
$wpdb->query( $wpdb->prepare(
- "UPDATE {$table} SET status = 'expired', error_code = 'authorization_expired', finished_at = %s, lease_hash = NULL, lease_until = NULL, confirmation_token_hash = NULL WHERE status IN ('awaiting_confirmation','queued','leased','running') AND authorization_expires_at <= %s",
+ "UPDATE {$table} SET status = 'expired', error_code = 'authorization_expired', finished_at = %s, lease_hash = NULL, lease_until = NULL, confirmation_token_hash = NULL, control_token_hash = NULL, control_page_hash = NULL, control_lease_hash = NULL, control_lease_until = NULL, control_state = 'none' WHERE status IN ('awaiting_confirmation','awaiting_control','queued','leased','running') AND authorization_expires_at <= %s",
$now,
$now
) );
@@ -746,6 +792,16 @@ function ct_scan_maintenance_locked() {
$now,
$now
) );
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$table} SET control_state = 'pending', control_lease_hash = NULL, control_lease_until = NULL WHERE status = 'awaiting_control' AND control_state = 'checking' AND control_lease_until <= %s",
+ $now
+ ) );
+ // Every check was spent (even if the last result never arrived): the job is blocked, not left waiting.
+ $wpdb->query( $wpdb->prepare(
+ "UPDATE {$table} SET status = 'blocked', error_code = 'target_control_failed', finished_at = %s, control_state = 'none', control_token_hash = NULL, control_page_hash = NULL, control_lease_hash = NULL, control_lease_until = NULL WHERE status = 'awaiting_control' AND control_state = 'pending' AND control_attempts >= %d",
+ $now,
+ ct_scan_control_limits()['max_attempts']
+ ) );
$wpdb->query( $wpdb->prepare(
"UPDATE {$table} SET status = 'queued', lease_hash = NULL, lease_until = NULL WHERE status = 'leased' AND lease_until <= %s AND attempts = 0",
$now
@@ -755,7 +811,7 @@ function ct_scan_maintenance_locked() {
$now,
$now
) );
- $queued = $wpdb->get_results( "SELECT job_id,request_id,target_origin,scope_hash,terms_version,terms_hash,terms_accepted_at,authorization_expires_at,contact_email,contact_name,referrer,revoked_at,report_delivered_at,summary_sha256,bound_required,profile_id FROM {$table} WHERE status = 'queued'" );
+ $queued = $wpdb->get_results( "SELECT job_id,request_id,target_origin,scope_hash,terms_version,terms_hash,terms_accepted_at,authorization_expires_at,contact_email,contact_name,referrer,revoked_at,report_delivered_at,summary_sha256,bound_required,profile_id,status,control_required,control_verified_at,control_valid_until FROM {$table} WHERE status = 'queued'" );
foreach ( (array) $queued as $row ) {
if ( ! ct_scan_authorization_is_live( $row ) ) {
$wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => $now, 'lease_hash' => null, 'lease_until' => null ), array( 'job_id' => (int) $row->job_id ), array( '%s', '%s', '%s', '%s', '%s' ), array( '%d' ) );
@@ -771,7 +827,7 @@ function ct_scan_maintenance_locked() {
$result_cutoff
) );
$request_cutoff = ( new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) ) )->modify( '-3 years' )->format( 'Y-m-d H:i:s' );
- $old_rows = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,request_id FROM {$table} WHERE terms_accepted_at < %s AND status IN ('completed','failed','failed_unknown','cancelled','expired')", $request_cutoff ) );
+ $old_rows = $wpdb->get_results( $wpdb->prepare( "SELECT job_id,request_id FROM {$table} WHERE terms_accepted_at < %s AND status IN ('completed','failed','failed_unknown','cancelled','expired','blocked')", $request_cutoff ) );
foreach ( (array) $old_rows as $row ) {
$post = get_post( (int) $row->request_id );
$personal_scrubbed = $wpdb->update(
@@ -839,52 +895,53 @@ function ct_scan_send_confirmation_page( $message, $status = 200, $markup = '' )
exit;
}
-function ct_scan_confirmation_handler() {
- $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : '';
- if ( 'GET' === $method ) {
- $token = ct_scan_confirmation_token_from_request( $_GET );
- if ( '' === $token ) {
- ct_scan_send_confirmation_page( 'This confirmation link is invalid or expired.', 404 );
- }
- $row = ct_scan_row_by_confirmation_hash( hash( 'sha256', $token ) );
- if ( ! $row || 'awaiting_confirmation' !== $row->status || strtotime( $row->confirmation_expires_at . ' UTC' ) <= time()
- || ! ct_scan_authorization_is_live( $row ) ) {
- ct_scan_send_confirmation_page( 'This confirmation link is invalid or expired.', 410 );
- }
- $nonce = wp_create_nonce( 'ct_scan_confirm_' . (int) $row->job_id . '_' . $token );
- ct_scan_send_confirmation_page( '', 200, ct_scan_confirmation_markup( (int) $row->job_id, $token, $nonce, $row->target_origin, $row->terms_version, $row->terms_accepted_at ) );
- }
- if ( 'POST' !== $method ) {
- ct_scan_send_confirmation_page( 'Use the confirmation form to continue.', 405 );
- }
- $token = ct_scan_confirmation_token_from_request( $_POST );
- $job_id = isset( $_POST['job_id'] ) && is_scalar( $_POST['job_id'] ) ? absint( wp_unslash( $_POST['job_id'] ) ) : 0;
- if ( ! $job_id || '' === $token || ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] )
- || ! wp_verify_nonce( wp_unslash( $_POST['_wpnonce'] ), 'ct_scan_confirm_' . $job_id . '_' . $token ) ) {
- ct_scan_send_confirmation_page( 'This confirmation form is invalid or expired.', 403 );
- }
+/**
+ * The state change behind the confirmation POST (the caller has verified the form and nonce).
+ * Returns array( message, http status, optional page markup ).
+ */
+function ct_scan_confirm_job( $job_id, $token ) {
if ( ! ct_scan_lock_acquire() ) {
- ct_scan_send_confirmation_page( 'The request is temporarily busy. Please try again.', 503 );
+ return array( 'The request is temporarily busy. Please try again.', 503, '' );
}
global $wpdb;
$table = ct_scan_jobs_table();
$message = 'This confirmation link is invalid or expired.';
$response_status = 410;
+ $control_markup = '';
try {
$row = ct_scan_row( $job_id );
if ( $row && 'awaiting_confirmation' === $row->status && ! empty( $row->confirmation_token_hash )
&& hash_equals( $row->confirmation_token_hash, hash( 'sha256', $token ) )
&& strtotime( $row->confirmation_expires_at . ' UTC' ) > time() && ct_scan_authorization_is_live( $row ) ) {
+ // Mailbox control is not control of the website: a job that requires target control waits
+ // for it. The challenge token is shown once; only its hash is stored.
+ $needs_control = ct_scan_control_required( $row );
+ // Two independent secrets: the challenge value (published by design, so world-readable) and
+ // the page capability that lets the holder request checks (never published).
+ $control_page = $needs_control ? bin2hex( random_bytes( 32 ) ) : '';
+ $control_token = $needs_control ? ct_scan_control_challenge( $row, $control_page ) : '';
+ $fields = array( 'status' => $needs_control ? 'awaiting_control' : 'queued', 'confirmed_at' => ct_scan_utc_now(), 'confirmation_token_hash' => null, 'confirmation_expires_at' => null );
+ if ( $needs_control ) {
+ $fields['control_token_hash'] = hash( 'sha256', $control_token );
+ $fields['control_page_hash'] = hash( 'sha256', $control_page );
+ $fields['control_state'] = 'pending';
+ }
$updated = $wpdb->update(
$table,
- array( 'status' => 'queued', 'confirmed_at' => ct_scan_utc_now(), 'confirmation_token_hash' => null, 'confirmation_expires_at' => null ),
+ $fields,
array( 'job_id' => $job_id, 'status' => 'awaiting_confirmation', 'confirmation_token_hash' => $row->confirmation_token_hash ),
- array( '%s', '%s', '%s', '%s' ),
+ array_fill( 0, count( $fields ), '%s' ),
array( '%d', '%s', '%s' )
);
if ( 1 === $updated ) {
- ct_scan_mark_request_status( (int) $row->request_id, 'queued' );
- $message = 'Your screening is confirmed and queued. It will run once, within the authorization window.';
+ if ( $needs_control ) {
+ ct_scan_mark_request_status( (int) $row->request_id, 'awaiting_control' );
+ $control_markup = ct_scan_control_markup( $row, $control_page, '', 0 );
+ $message = 'Your email is confirmed. No screening will run until control of the website is shown.';
+ } else {
+ ct_scan_mark_request_status( (int) $row->request_id, 'queued' );
+ $message = 'Your screening is confirmed and queued. It will run once, within the authorization window.';
+ }
$response_status = 200;
}
}
@@ -899,7 +956,35 @@ function ct_scan_confirmation_handler() {
unset( $error );
}
}
- ct_scan_send_confirmation_page( $message, $response_status );
+ return array( $message, $response_status, $control_markup );
+}
+
+function ct_scan_confirmation_handler() {
+ $method = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : '';
+ if ( 'GET' === $method ) {
+ $token = ct_scan_confirmation_token_from_request( $_GET );
+ if ( '' === $token ) {
+ ct_scan_send_confirmation_page( 'This confirmation link is invalid or expired.', 404 );
+ }
+ $row = ct_scan_row_by_confirmation_hash( hash( 'sha256', $token ) );
+ if ( ! $row || 'awaiting_confirmation' !== $row->status || strtotime( $row->confirmation_expires_at . ' UTC' ) <= time()
+ || ! ct_scan_authorization_is_live( $row ) ) {
+ ct_scan_send_confirmation_page( 'This confirmation link is invalid or expired.', 410 );
+ }
+ $nonce = wp_create_nonce( 'ct_scan_confirm_' . (int) $row->job_id . '_' . $token );
+ ct_scan_send_confirmation_page( '', 200, ct_scan_confirmation_markup( (int) $row->job_id, $token, $nonce, $row->target_origin, $row->terms_version, $row->terms_accepted_at ) );
+ }
+ if ( 'POST' !== $method ) {
+ ct_scan_send_confirmation_page( 'Use the confirmation form to continue.', 405 );
+ }
+ $token = ct_scan_confirmation_token_from_request( $_POST );
+ $job_id = isset( $_POST['job_id'] ) && is_scalar( $_POST['job_id'] ) ? absint( wp_unslash( $_POST['job_id'] ) ) : 0;
+ if ( ! $job_id || '' === $token || ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] )
+ || ! wp_verify_nonce( wp_unslash( $_POST['_wpnonce'] ), 'ct_scan_confirm_' . $job_id . '_' . $token ) ) {
+ ct_scan_send_confirmation_page( 'This confirmation form is invalid or expired.', 403 );
+ }
+ list( $message, $response_status, $markup ) = ct_scan_confirm_job( $job_id, $token );
+ ct_scan_send_confirmation_page( $message, $response_status, $markup );
}
/**
@@ -930,8 +1015,9 @@ function ct_scan_send_accepted_copy( $job_id ) {
return new WP_Error( 'accepted_copy_unavailable', 'The accepted copy requires the confirmed, authorized recipient.' );
}
// Re-checked under the queue lock right before the claim: a job that was cancelled, stopped,
- // expired or whose evidence changed since confirmation gets no copy.
- if ( ! in_array( $row->status, array( 'queued', 'leased', 'running' ), true ) || ! ct_scan_authorization_is_live( $row ) ) {
+ // expired or whose evidence changed since confirmation gets no copy. A confirmed job waiting for
+ // proof of target control is live (confirmation sends the copy then); control gates the scan, not the copy.
+ if ( ! in_array( $row->status, array( 'awaiting_control', 'queued', 'leased', 'running' ), true ) || ! ct_scan_authorization_is_live( $row ) ) {
return new WP_Error( 'accepted_copy_unavailable', 'The accepted copy requires a live authorization.' );
}
$claimed = $wpdb->update(
@@ -1009,6 +1095,187 @@ function ct_scan_row_by_confirmation_hash( $hash ) {
add_action( 'admin_post_nopriv_ct_scan_confirm', 'ct_scan_confirmation_handler' );
add_action( 'admin_post_ct_scan_confirm', 'ct_scan_confirmation_handler' );
+
+/**
+ * Exact-target control. Mailbox confirmation shows control of an email address, not of the website.
+ * For jobs frozen with control_required the customer proves control of the exact hostname by
+ * publishing the challenge as a DNS TXT record at `_ct-scan-challenge.` (value
+ * `ct-scan-verify=`) or serving it at `https:///.well-known/ct-scan-challenge.txt`, then
+ * asks for a check. At most three checks, one minute apart, each one TXT lookup or one HTTPS request
+ * (fixed path, no redirects, 4 KiB, 10 s). A recorded manual authority review can stand in. A failed
+ * challenge never falls back to a scan: after three failures the job is `blocked`.
+ * TODO(owner): the wording of these customer-facing pages is functional only.
+ */
+/**
+ * The challenge value shown to the customer. It is derived from the page capability with a keyed
+ * hash, so the page can show it again without storing it, while someone who reads the published
+ * challenge learns nothing that lets them open the page and use up the checks.
+ */
+function ct_scan_control_challenge( $row, $page_token ) {
+ return hash_hmac( 'sha256', 'ct-scan-challenge-v1|' . (int) $row->job_id . '|' . $row->scope_hash, $page_token );
+}
+
+function ct_scan_control_markup( $row, $page_token, $notice, $attempts_used ) {
+ $limits = ct_scan_control_limits();
+ $host = (string) wp_parse_url( $row->target_origin, PHP_URL_HOST );
+ $challenge = ct_scan_control_challenge( $row, $page_token );
+ $action = esc_url( admin_url( 'admin-post.php' ) );
+ $nonce = wp_create_nonce( 'ct_scan_control_' . (int) $row->job_id . '_' . $page_token );
+ $link = esc_url( add_query_arg( array( 'action' => 'ct_scan_control', 'job' => (int) $row->job_id, 'ctl' => $page_token ), admin_url( 'admin-post.php' ) ) );
+ $html = 'Show control of your website Show control of your website ';
+ if ( '' !== $notice ) {
+ $html .= '' . esc_html( $notice ) . '
';
+ }
+ $html .= 'No screening runs until control of ' . esc_html( $host ) . ' is shown. Publish ONE of the following, then press Check.
';
+ $html .= 'DNS TXT record at ' . esc_html( $limits['dns_label'] . '.' . $host ) . ' with the value:' . esc_html( $limits['dns_prefix'] . $challenge ) . 'The TXT record must be at that exact name: a CNAME there is not followed. Wait until the record is visible in public DNS before checking; a check made too early can be answered from a cached "no record" and still uses one of your checks.
';
+ $html .= 'or the file https://' . esc_html( $host . $limits['path'] ) . ' whose entire content is:' . esc_html( $challenge ) . '
';
+ $html .= 'Keep this page\'s address private: it is the only way to return here and to request checks. Checks used: ' . (int) $attempts_used . ' of ' . (int) $limits['max_attempts'] . '; at least one minute apart.
';
+ $html .= '';
+ $html .= 'Return address: ' . $link . '
';
+ return $html;
+}
+
+function ct_scan_control_token_from_request( $source ) {
+ if ( ! isset( $source['ctl'] ) || ! is_string( $source['ctl'] ) ) {
+ return '';
+ }
+ $token = wp_unslash( $source['ctl'] );
+ return preg_match( '/\A[a-f0-9]{64}\z/i', $token ) ? strtolower( $token ) : '';
+}
+
+/** Schedule one control check for the customer's chosen method, under the disclosed limits. */
+function ct_scan_request_control_check( $job_id, $token, $method ) {
+ global $wpdb;
+ $limits = ct_scan_control_limits();
+ if ( ! in_array( $method, array( 'dns_txt', 'https_file' ), true ) ) {
+ return new WP_Error( 'control_method_invalid', 'Choose the DNS record or the file.' );
+ }
+ if ( ! ct_scan_lock_acquire( 3 ) ) {
+ return new WP_Error( 'queue_busy', 'The request is temporarily busy. Please try again.' );
+ }
+ $table = ct_scan_jobs_table();
+ try {
+ $row = ct_scan_row( $job_id );
+ if ( ! $row || 'awaiting_control' !== $row->status || empty( $row->control_page_hash ) || ! hash_equals( $row->control_page_hash, hash( 'sha256', $token ) )
+ || ! ct_scan_authorization_is_live( $row ) ) {
+ return new WP_Error( 'control_link_invalid', 'This link is invalid or expired.' );
+ }
+ if ( 'pending' !== $row->control_state ) {
+ return new WP_Error( 'control_check_in_progress', 'A check is already scheduled or running.' );
+ }
+ $now_ms = (int) floor( microtime( true ) * 1000 );
+ if ( (int) $row->control_attempts >= $limits['max_attempts'] ) {
+ return new WP_Error( 'control_attempts_exhausted', 'All checks were used.' );
+ }
+ if ( null !== $row->control_last_attempt_ms && $now_ms < (int) $row->control_last_attempt_ms + $limits['min_interval_ms'] ) {
+ return new WP_Error( 'control_too_soon', 'Wait at least one minute between checks.' );
+ }
+ $updated = $wpdb->update(
+ $table,
+ array( 'control_state' => 'requested', 'control_requested_method' => $method ),
+ array( 'job_id' => (int) $row->job_id, 'status' => 'awaiting_control', 'control_state' => 'pending', 'control_attempts' => (int) $row->control_attempts ),
+ array( '%s', '%s' ),
+ array( '%d', '%s', '%s', '%d' )
+ );
+ if ( 1 !== $updated ) {
+ return new WP_Error( 'control_write_failed', 'The check could not be scheduled. Please try again.' );
+ }
+ } finally {
+ ct_scan_lock_release();
+ }
+ return 'scheduled';
+}
+
+function ct_scan_control_handler() {
+ $method_http = isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : '';
+ $source = 'POST' === $method_http ? $_POST : $_GET;
+ $token = ct_scan_control_token_from_request( $source );
+ $job_id = isset( $source['job'] ) && is_scalar( $source['job'] ) ? absint( wp_unslash( $source['job'] ) ) : 0;
+ if ( ! in_array( $method_http, array( 'GET', 'POST' ), true ) ) {
+ ct_scan_send_confirmation_page( 'Use the link you were given.', 405 );
+ }
+ if ( ! $job_id || '' === $token ) {
+ ct_scan_send_confirmation_page( 'This link is invalid or expired.', 404 );
+ }
+ $row = ct_scan_row( $job_id );
+ if ( ! $row || 'awaiting_control' !== $row->status || empty( $row->control_page_hash ) || ! hash_equals( $row->control_page_hash, hash( 'sha256', $token ) ) || ! ct_scan_authorization_is_live( $row ) ) {
+ ct_scan_send_confirmation_page( 'This link is invalid or expired.', 410 );
+ }
+ if ( 'GET' === $method_http ) {
+ ct_scan_send_confirmation_page( '', 200, ct_scan_control_markup( $row, $token, '', (int) $row->control_attempts ) );
+ }
+ if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) || ! wp_verify_nonce( wp_unslash( $_POST['_wpnonce'] ), 'ct_scan_control_' . $job_id . '_' . $token ) ) {
+ ct_scan_send_confirmation_page( 'This form is invalid or expired. Open the link again.', 403 );
+ }
+ $choice = isset( $_POST['method'] ) && is_string( $_POST['method'] ) ? sanitize_key( wp_unslash( $_POST['method'] ) ) : '';
+ $result = ct_scan_request_control_check( $job_id, $token, $choice );
+ $notice = array(
+ 'scheduled' => 'Check scheduled. It runs within a minute; return to this page to see the outcome.',
+ 'control_check_in_progress' => 'A check is already scheduled or running.',
+ 'control_too_soon' => 'Wait at least one minute between checks.',
+ 'control_attempts_exhausted' => 'All checks were used.',
+ 'control_method_invalid' => 'Choose the DNS record or the file.',
+ 'queue_busy' => 'The request is temporarily busy. Please try again.',
+ );
+ $key = is_wp_error( $result ) ? $result->get_error_code() : $result;
+ $current = ct_scan_row( $job_id );
+ $current = $current ? $current : $row;
+ ct_scan_send_confirmation_page( '', 200, ct_scan_control_markup( $current, $token, isset( $notice[ $key ] ) ? $notice[ $key ] : 'The check could not be scheduled.', (int) $current->control_attempts ) );
+}
+add_action( 'admin_post_nopriv_ct_scan_control', 'ct_scan_control_handler' );
+add_action( 'admin_post_ct_scan_control', 'ct_scan_control_handler' );
+
+/** Recorded manual authority review: names the reviewer, evidence, target, time and expiry. */
+function ct_scan_admin_record_authority_review( $job_id, $evidence_note ) {
+ global $wpdb;
+ $user = wp_get_current_user();
+ if ( ! $user || ! $user->exists() || ! current_user_can( 'manage_options' ) ) {
+ return new WP_Error( 'authority_review_forbidden', 'Only an administrator can record an authority review.' );
+ }
+ $note = is_string( $evidence_note ) ? trim( wp_strip_all_tags( $evidence_note ) ) : '';
+ if ( strlen( $note ) < 20 || strlen( $note ) > 1000 ) {
+ return new WP_Error( 'authority_review_evidence_required', 'Describe the evidence reviewed (20 to 1000 characters).' );
+ }
+ if ( ! ct_scan_lock_acquire( 3 ) ) {
+ return new WP_Error( 'queue_busy', 'The queue is temporarily busy.' );
+ }
+ $table = ct_scan_jobs_table();
+ try {
+ $row = ct_scan_row( $job_id );
+ if ( ! $row || 'awaiting_control' !== $row->status || empty( $row->control_required ) || ! empty( $row->revoked_at ) || ! ct_scan_authorization_is_live( $row ) ) {
+ return new WP_Error( 'authority_review_unavailable', 'This job is not waiting for target control.' );
+ }
+ if ( 'checking' === $row->control_state ) {
+ return new WP_Error( 'authority_review_check_in_progress', 'A DNS or file check for this job is in progress. Wait for its result (at most two minutes), then reload this page and record the review if the job is still waiting.' );
+ }
+ $now = ct_scan_utc_now();
+ $evidence = wp_json_encode( array(
+ 'method' => 'manual_review',
+ 'reviewer_id' => (int) $user->ID,
+ 'reviewer' => sanitize_user( $user->user_login, true ),
+ 'target' => $row->target_origin,
+ 'evidence' => $note,
+ 'reviewed_at' => $now,
+ 'valid_until' => $row->authorization_expires_at,
+ ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+ $updated = $wpdb->update(
+ $table,
+ array( 'status' => 'queued', 'control_state' => 'none', 'control_method' => 'manual_review', 'control_verified_at' => $now, 'control_valid_until' => $row->authorization_expires_at, 'control_evidence' => $evidence, 'control_token_hash' => null, 'control_page_hash' => null, 'control_lease_hash' => null, 'control_lease_until' => null ),
+ array( 'job_id' => (int) $row->job_id, 'status' => 'awaiting_control', 'control_state' => $row->control_state ),
+ array_fill( 0, 10, '%s' ),
+ array( '%d', '%s', '%s' )
+ );
+ if ( 1 !== $updated ) {
+ return new WP_Error( 'authority_review_write_failed', 'The review could not be durably recorded.' );
+ }
+ ct_scan_mark_request_status( (int) $row->request_id, 'queued' );
+ } finally {
+ ct_scan_lock_release();
+ }
+ return true;
+}
+
function ct_scan_stop_token_from_request( $source ) {
if ( ! isset( $source['stop'] ) || ! is_string( $source['stop'] ) ) {
return '';
@@ -1051,7 +1318,7 @@ function ct_scan_customer_stop( $job_id, $token ) {
$now = ct_scan_utc_now();
$updated = $wpdb->update(
$table,
- array( 'status' => 'cancelled', 'error_code' => 'customer_stopped', 'finished_at' => $now, 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, 'lease_hash' => null, 'lease_until' => null ),
+ array( 'status' => 'cancelled', 'error_code' => 'customer_stopped', 'finished_at' => $now, 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, 'lease_hash' => null, 'lease_until' => null, 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null ),
array( 'job_id' => (int) $row->job_id, 'status' => $row->status ),
null,
array( '%d', '%s' )
@@ -1188,7 +1455,8 @@ function ct_scan_claim( WP_REST_Request $request ) {
$table = ct_scan_jobs_table();
try {
ct_scan_maintenance_locked();
- $active = $wpdb->get_var( "SELECT job_id FROM {$table} WHERE status IN ('leased','running') LIMIT 1" );
+ // One piece of target traffic at a time worker-wide: a scan, or a control check in progress.
+ $active = $wpdb->get_var( "SELECT job_id FROM {$table} WHERE status IN ('leased','running') OR (status = 'awaiting_control' AND control_state = 'checking') LIMIT 1" );
if ( $active ) {
return ct_scan_rest_response( 200, array( 'job' => null ) );
}
@@ -1392,7 +1660,7 @@ function ct_scan_admit( WP_REST_Request $request ) {
}
$data = ct_scan_decode_request_body( $body );
if ( is_wp_error( $data ) || ! ct_scan_expected_keys( $data, array( 'id', 'lease', 'scope_hash', 'kind' ) ) || ! ct_scan_callback_payload( $data )
- || ! is_string( $data['kind'] ) || ! in_array( $data['kind'], array( 'http', 'probe' ), true ) ) {
+ || ! is_string( $data['kind'] ) || ! in_array( $data['kind'], array( 'http', 'probe', 'control', 'control_probe' ), true ) ) {
return ct_scan_rest_error( 400, 'request_invalid' );
}
if ( ! ct_scan_lock_acquire() ) {
@@ -1400,7 +1668,10 @@ function ct_scan_admit( WP_REST_Request $request ) {
return ct_scan_rest_response( 503, array( 'ok' => false, 'reason' => 'queue_busy' ) );
}
try {
- $decision = ct_scan_admit_locked( $data, (int) floor( microtime( true ) * 1000 ) );
+ $now_ms = (int) floor( microtime( true ) * 1000 );
+ $decision = in_array( $data['kind'], array( 'control', 'control_probe' ), true )
+ ? ct_scan_admit_control_locked( $data, $now_ms )
+ : ct_scan_admit_locked( $data, $now_ms );
} catch ( Throwable $error ) {
$decision = array( 503, array( 'ok' => false, 'reason' => 'admission_error' ) );
} finally {
@@ -1608,6 +1879,265 @@ function ct_scan_result( WP_REST_Request $request ) {
}
}
+/**
+ * Admission for the single request of a control check (kind `control`) or a liveness probe of a
+ * control task (`control_probe`). Same contract as ct_scan_admit_locked(): called under the queue
+ * lock, refuses on any doubt, and consumes the attempt in the same compare-and-set as the decision.
+ * The check budget is the disclosed three attempts, one minute apart.
+ */
+function ct_scan_admit_control_locked( $data, $now_ms ) {
+ global $wpdb;
+ $table = ct_scan_jobs_table();
+ $limits = ct_scan_control_limits();
+ $refuse = static function ( $status, $reason, $extra = array() ) {
+ return array( (int) $status, array_merge( array( 'ok' => false, 'reason' => sanitize_key( $reason ) ), $extra ) );
+ };
+ $row = ct_scan_row( $data['id'] );
+ if ( ! $row ) {
+ return $refuse( 410, 'job_not_found' );
+ }
+ if ( 'awaiting_control' === $row->status && strtotime( $row->authorization_expires_at . ' UTC' ) <= time() ) {
+ $wpdb->update( $table, array( 'status' => 'expired', 'error_code' => 'authorization_expired', 'finished_at' => ct_scan_utc_now(), 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null ), array( 'job_id' => (int) $row->job_id, 'status' => 'awaiting_control' ), null, array( '%d', '%s' ) );
+ ct_scan_mark_request_status( (int) $row->request_id, 'expired', 'authorization_expired' );
+ return $refuse( 410, 'job_expired' );
+ }
+ if ( 'cancelled' === $row->status ) {
+ return $refuse( 410, 'job_cancelled' );
+ }
+ if ( 'expired' === $row->status ) {
+ return $refuse( 410, 'job_expired' );
+ }
+ if ( 'awaiting_control' !== $row->status || empty( $row->control_required ) ) {
+ return $refuse( 410, 'job_not_awaiting_control' );
+ }
+ if ( 'checking' !== $row->control_state || empty( $row->control_lease_hash ) || ! hash_equals( $row->control_lease_hash, hash( 'sha256', $data['lease'] ) )
+ || ! hash_equals( $row->scope_hash, $data['scope_hash'] ) || strtotime( $row->control_lease_until . ' UTC' ) <= time() ) {
+ return $refuse( 410, 'lease_invalid_or_expired' );
+ }
+ if ( empty( $row->confirmed_at ) ) {
+ return $refuse( 410, 'recipient_unconfirmed' );
+ }
+ if ( ! ct_scan_authorization_is_live( $row ) ) {
+ $wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => ct_scan_utc_now(), 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null ), array( 'job_id' => (int) $row->job_id ), null, array( '%d' ) );
+ ct_scan_mark_request_status( (int) $row->request_id, 'cancelled', 'authorization_changed' );
+ return $refuse( 410, 'authorization_revoked_or_expired' );
+ }
+ if ( 'control_probe' === $data['kind'] ) {
+ return array( 200, array( 'ok' => true, 'live' => true ) );
+ }
+ // One lease, one check: a lease that was already admitted is never admitted again, however
+ // long it has left. The lease (two minutes) outlives the one-minute interval between checks.
+ if ( ! empty( $row->control_admitted ) ) {
+ return $refuse( 409, 'already_admitted' );
+ }
+ $used = (int) $row->control_attempts;
+ if ( $used >= $limits['max_attempts'] ) {
+ return $refuse( 409, 'budget_exhausted' );
+ }
+ if ( null !== $row->control_last_attempt_ms ) {
+ $last = (int) $row->control_last_attempt_ms;
+ if ( $now_ms + $limits['min_interval_ms'] < $last ) {
+ return $refuse( 410, 'clock_anomaly' );
+ }
+ $wait = $last + $limits['min_interval_ms'] - $now_ms;
+ if ( $wait > 0 ) {
+ return $refuse( 429, 'rate_wait', array( 'retry_after_ms' => (int) $wait ) );
+ }
+ }
+ $updated = $wpdb->update(
+ $table,
+ array( 'control_attempts' => $used + 1, 'control_last_attempt_ms' => $now_ms, 'control_admitted' => 1 ),
+ array( 'job_id' => (int) $row->job_id, 'status' => 'awaiting_control', 'control_state' => 'checking', 'control_lease_hash' => $row->control_lease_hash, 'control_attempts' => $used, 'control_admitted' => 0 ),
+ array( '%d', '%d', '%d' ),
+ array( '%d', '%s', '%s', '%s', '%d', '%d' )
+ );
+ if ( 1 !== $updated ) {
+ return $refuse( 503, 'admission_write_failed' );
+ }
+ return array( 200, array(
+ 'ok' => true,
+ 'admitted' => true,
+ 'sequence' => $used + 1,
+ 'valid_ms' => (int) $limits['valid_ms'],
+ 'timeout_ms' => (int) $limits['timeout_ms'],
+ 'http_remaining' => (int) ( $limits['max_attempts'] - $used - 1 ),
+ ) );
+}
+
+/** Worker: take one scheduled control check. Consumes nothing; the attempt is consumed at admission. */
+function ct_scan_control_claim( WP_REST_Request $request ) {
+ $body = ct_scan_rest_authenticate( $request, '/ct-scan/v1/control/claim' );
+ if ( is_wp_error( $body ) ) {
+ return ct_scan_rest_error( (int) $body->get_error_data()['status'], $body->get_error_code() );
+ }
+ $data = ct_scan_decode_request_body( $body );
+ if ( is_wp_error( $data ) || ! ct_scan_expected_keys( $data, array() ) ) {
+ return ct_scan_rest_error( 400, 'request_invalid' );
+ }
+ if ( ! ct_scan_lock_acquire() ) {
+ return ct_scan_rest_error( 503, 'queue_busy' );
+ }
+ global $wpdb;
+ $table = ct_scan_jobs_table();
+ $limits = ct_scan_control_limits();
+ try {
+ ct_scan_maintenance_locked();
+ $busy = $wpdb->get_var( "SELECT job_id FROM {$table} WHERE status IN ('leased','running') OR (status = 'awaiting_control' AND control_state = 'checking') LIMIT 1" );
+ if ( $busy ) {
+ return ct_scan_rest_response( 200, array( 'task' => null ) );
+ }
+ $row = $wpdb->get_row( "SELECT * FROM {$table} WHERE status = 'awaiting_control' AND control_state = 'requested' ORDER BY job_id ASC LIMIT 1" );
+ if ( ! $row ) {
+ return ct_scan_rest_response( 200, array( 'task' => null ) );
+ }
+ if ( ! ct_scan_authorization_is_live( $row ) || empty( $row->confirmed_at ) || ! in_array( $row->control_requested_method, array( 'dns_txt', 'https_file' ), true ) ) {
+ $wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => ct_scan_utc_now(), 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none' ), array( 'job_id' => (int) $row->job_id ), null, array( '%d' ) );
+ ct_scan_mark_request_status( (int) $row->request_id, 'cancelled', 'authorization_changed' );
+ return ct_scan_rest_response( 200, array( 'task' => null ) );
+ }
+ $lease = bin2hex( random_bytes( 32 ) );
+ $expiry = strtotime( $row->authorization_expires_at . ' UTC' );
+ $lease_until = min( time() + $limits['lease_seconds'], $expiry );
+ if ( $lease_until <= time() ) {
+ return ct_scan_rest_response( 200, array( 'task' => null ) );
+ }
+ $updated = $wpdb->update(
+ $table,
+ array( 'control_state' => 'checking', 'control_lease_hash' => hash( 'sha256', $lease ), 'control_lease_until' => gmdate( 'Y-m-d H:i:s', $lease_until ), 'control_leased_ms' => (int) floor( microtime( true ) * 1000 ), 'control_admitted' => 0 ),
+ array( 'job_id' => (int) $row->job_id, 'status' => 'awaiting_control', 'control_state' => 'requested' ),
+ array( '%s', '%s', '%s', '%d', '%d' ),
+ array( '%d', '%s', '%s' )
+ );
+ if ( 1 !== $updated ) {
+ return ct_scan_rest_error( 503, 'claim_race' );
+ }
+ return ct_scan_rest_response( 200, array(
+ 'task' => array(
+ 'id' => (int) $row->job_id,
+ 'target' => $row->target_origin,
+ 'scope_hash' => $row->scope_hash,
+ 'method' => $row->control_requested_method,
+ 'expires_at' => (int) $expiry,
+ 'lease' => $lease,
+ 'lease_until' => (int) $lease_until,
+ ),
+ ) );
+ } finally {
+ ct_scan_lock_release();
+ }
+}
+
+/** Does an observed value show the challenge? Only hashes of the token are stored. */
+function ct_scan_control_value_matches( $method, $value, $token_hash ) {
+ $limits = ct_scan_control_limits();
+ if ( ! is_string( $value ) || ! is_string( $token_hash ) ) {
+ return false;
+ }
+ if ( 'dns_txt' === $method ) {
+ $prefix = $limits['dns_prefix'];
+ if ( 0 !== strpos( $value, $prefix ) ) {
+ return false;
+ }
+ $token = substr( $value, strlen( $prefix ) );
+ } else {
+ // Editors on some systems save a UTF-8 byte-order mark first; it is not part of the value.
+ if ( 0 === strncmp( $value, "\xEF\xBB\xBF", 3 ) ) {
+ $value = substr( $value, 3 );
+ }
+ $token = trim( $value, " \t\r\n" );
+ }
+ return 1 === preg_match( '/\A[a-f0-9]{64}\z/', $token ) && hash_equals( $token_hash, hash( 'sha256', $token ) );
+}
+
+/** Worker: report the outcome of the one admitted control request. */
+function ct_scan_control_result( WP_REST_Request $request ) {
+ $body = ct_scan_rest_authenticate( $request, '/ct-scan/v1/control/result' );
+ if ( is_wp_error( $body ) ) {
+ return ct_scan_rest_error( (int) $body->get_error_data()['status'], $body->get_error_code() );
+ }
+ $data = ct_scan_decode_request_body( $body );
+ $limits = ct_scan_control_limits();
+ if ( is_wp_error( $data ) || ! ct_scan_expected_keys( $data, array( 'id', 'lease', 'scope_hash', 'method', 'outcome', 'observed' ) ) || ! ct_scan_callback_payload( $data )
+ || ! in_array( $data['method'], array( 'dns_txt', 'https_file' ), true ) || ! in_array( $data['outcome'], array( 'observed', 'not_found', 'error' ), true )
+ || ! is_array( $data['observed'] ) || ! ct_scan_is_list( $data['observed'] ) || count( $data['observed'] ) > 8 ) {
+ return ct_scan_rest_error( 400, 'request_invalid' );
+ }
+ $total = 0;
+ foreach ( $data['observed'] as $value ) {
+ if ( ! is_string( $value ) || strlen( $value ) > $limits['max_bytes'] ) {
+ return ct_scan_rest_error( 400, 'request_invalid' );
+ }
+ $total += strlen( $value );
+ }
+ if ( $total > $limits['max_bytes'] || ( 'observed' !== $data['outcome'] && array() !== $data['observed'] ) ) {
+ return ct_scan_rest_error( 400, 'request_invalid' );
+ }
+ if ( ! ct_scan_lock_acquire() ) {
+ return ct_scan_rest_error( 503, 'queue_busy' );
+ }
+ global $wpdb;
+ $table = ct_scan_jobs_table();
+ try {
+ ct_scan_maintenance_locked();
+ $row = ct_scan_row( $data['id'] );
+ if ( ! $row || 'awaiting_control' !== $row->status || 'checking' !== $row->control_state || empty( $row->control_lease_hash )
+ || ! hash_equals( $row->control_lease_hash, hash( 'sha256', $data['lease'] ) ) || ! hash_equals( $row->scope_hash, $data['scope_hash'] )
+ || strtotime( $row->control_lease_until . ' UTC' ) <= time() || $row->control_requested_method !== $data['method'] ) {
+ return ct_scan_rest_error( 410, 'lease_invalid_or_expired' );
+ }
+ // The result of a check counts only if the guard admitted that check under this lease.
+ if ( empty( $row->control_admitted ) || null === $row->control_last_attempt_ms || null === $row->control_leased_ms || (int) $row->control_last_attempt_ms < (int) $row->control_leased_ms ) {
+ return ct_scan_rest_error( 409, 'no_admission' );
+ }
+ if ( ! ct_scan_authorization_is_live( $row ) ) {
+ $wpdb->update( $table, array( 'status' => 'cancelled', 'error_code' => 'authorization_changed', 'finished_at' => ct_scan_utc_now(), 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null ), array( 'job_id' => (int) $row->job_id ), null, array( '%d' ) );
+ ct_scan_mark_request_status( (int) $row->request_id, 'cancelled', 'authorization_changed' );
+ return ct_scan_rest_error( 410, 'authorization_revoked_or_expired' );
+ }
+ $matched = '';
+ if ( 'observed' === $data['outcome'] ) {
+ foreach ( $data['observed'] as $value ) {
+ if ( ct_scan_control_value_matches( $data['method'], $value, (string) $row->control_token_hash ) ) {
+ $matched = $value;
+ break;
+ }
+ }
+ }
+ $now = ct_scan_utc_now();
+ if ( '' !== $matched ) {
+ $evidence = wp_json_encode( array(
+ 'method' => $data['method'],
+ 'attempt' => (int) $row->control_attempts,
+ 'checked_at' => $now,
+ 'target' => $row->target_origin,
+ 'observed_sha256' => hash( 'sha256', $matched ),
+ ), JSON_UNESCAPED_SLASHES );
+ $update = array( 'status' => 'queued', 'control_state' => 'none', 'control_method' => $data['method'], 'control_verified_at' => $now, 'control_valid_until' => $row->authorization_expires_at, 'control_evidence' => $evidence, 'control_token_hash' => null, 'control_page_hash' => null, 'control_lease_hash' => null, 'control_lease_until' => null );
+ $state = 'queued';
+ } elseif ( (int) $row->control_attempts >= $limits['max_attempts'] ) {
+ $update = array( 'status' => 'blocked', 'error_code' => 'target_control_failed', 'finished_at' => $now, 'control_state' => 'none', 'control_token_hash' => null, 'control_page_hash' => null, 'control_lease_hash' => null, 'control_lease_until' => null, 'control_evidence' => wp_json_encode( array( 'method' => $data['method'], 'attempt' => (int) $row->control_attempts, 'last_outcome' => $data['outcome'] ) ) );
+ $state = 'blocked';
+ } else {
+ $update = array( 'control_state' => 'pending', 'control_lease_hash' => null, 'control_lease_until' => null, 'control_evidence' => wp_json_encode( array( 'method' => $data['method'], 'attempt' => (int) $row->control_attempts, 'last_outcome' => $data['outcome'] ) ) );
+ $state = 'awaiting_control';
+ }
+ $updated = $wpdb->update(
+ $table,
+ $update,
+ array( 'job_id' => (int) $row->job_id, 'status' => 'awaiting_control', 'control_state' => 'checking', 'control_lease_hash' => $row->control_lease_hash ),
+ array_fill( 0, count( $update ), '%s' ),
+ array( '%d', '%s', '%s', '%s' )
+ );
+ if ( 1 !== $updated ) {
+ return ct_scan_rest_error( 409, 'result_rejected' );
+ }
+ ct_scan_mark_request_status( (int) $row->request_id, $state, 'blocked' === $state ? 'target_control_failed' : '' );
+ return ct_scan_rest_response( 200, array( 'ok' => true, 'verified' => '' !== $matched ) );
+ } finally {
+ ct_scan_lock_release();
+ }
+}
+
function ct_scan_register_rest_routes() {
register_rest_route( 'ct-scan/v1', '/claim', array(
'methods' => WP_REST_Server::CREATABLE,
@@ -1624,6 +2154,16 @@ function ct_scan_register_rest_routes() {
'callback' => 'ct_scan_admit',
'permission_callback' => '__return_true',
) );
+ register_rest_route( 'ct-scan/v1', '/control/claim', array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => 'ct_scan_control_claim',
+ 'permission_callback' => '__return_true',
+ ) );
+ register_rest_route( 'ct-scan/v1', '/control/result', array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => 'ct_scan_control_result',
+ 'permission_callback' => '__return_true',
+ ) );
register_rest_route( 'ct-scan/v1', '/result', array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'ct_scan_result',
@@ -1633,7 +2173,7 @@ function ct_scan_register_rest_routes() {
add_action( 'rest_api_init', 'ct_scan_register_rest_routes' );
function ct_scan_terminal_status( $status ) {
- return in_array( $status, array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired' ), true );
+ return in_array( $status, array( 'completed', 'failed', 'failed_unknown', 'cancelled', 'expired', 'blocked' ), true );
}
function ct_scan_email_safe_text( $value, $max = 2000 ) {
@@ -1729,7 +2269,7 @@ function ct_scan_admin_revoke( $job_id ) {
return new WP_Error( 'job_unavailable', 'This job cannot be revoked.' );
}
$now = ct_scan_utc_now();
- $update = array( 'revoked_at' => $now, 'confirmation_token_hash' => null, 'lease_hash' => null, 'lease_until' => null );
+ $update = array( 'revoked_at' => $now, 'confirmation_token_hash' => null, 'lease_hash' => null, 'lease_until' => null, 'control_token_hash' => null, 'control_page_hash' => null, 'control_state' => 'none', 'control_lease_hash' => null, 'control_lease_until' => null );
if ( ! ct_scan_terminal_status( $row->status ) ) {
$update['status'] = 'cancelled';
$update['error_code'] = 'operator_revoked';
@@ -1797,10 +2337,15 @@ function ct_scan_admin_send_report( $job_id ) {
if ( empty( $row->confirmed_at ) ) {
return new WP_Error( 'report_recipient_unconfirmed', 'The recipient never confirmed this request. Detailed findings cannot be sent.' );
}
+ // ...and, when the job required it, only for a target whose control was established.
+ if ( ct_scan_control_required( $row ) && empty( $row->control_verified_at ) ) {
+ return new WP_Error( 'report_target_unverified', 'Control of the target was never established. Detailed findings cannot be sent.' );
+ }
if ( ! is_email( $row->contact_email )
|| ct_scan_meta_string( (int) $row->request_id, '_ct_scan_email' ) !== $row->contact_email
|| ct_scan_meta_string( (int) $row->request_id, '_ct_terms_version' ) !== $row->terms_version
- || ct_scan_meta_string( (int) $row->request_id, '_ct_terms_sha256' ) !== $row->terms_hash ) {
+ // The job stores the recorded terms hash lowercased (as the accepted-copy path compares it).
+ || strtolower( ct_scan_meta_string( (int) $row->request_id, '_ct_terms_sha256' ) ) !== $row->terms_hash ) {
return new WP_Error( 'report_contact_changed', 'The original authorized report contact could not be verified.' );
}
$body = ct_scan_build_report_body( $row );
@@ -1856,7 +2401,7 @@ function ct_scan_admin_record_delivery( $job_id ) {
if ( 1 !== $updated ) {
return new WP_Error( 'report_delivery_write_failed', 'The delivery confirmation could not be durably saved.' );
}
- $cancelled = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = 'cancelled', error_code = 'report_delivered', finished_at = %s, lease_hash = NULL, lease_until = NULL, confirmation_token_hash = NULL WHERE job_id = %d AND status IN ('awaiting_confirmation','queued','leased','running')", $now, (int) $row->job_id ) );
+ $cancelled = $wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = 'cancelled', error_code = 'report_delivered', finished_at = %s, lease_hash = NULL, lease_until = NULL, confirmation_token_hash = NULL WHERE job_id = %d AND status IN ('awaiting_confirmation','awaiting_control','queued','leased','running')", $now, (int) $row->job_id ) );
if ( false === $cancelled ) {
return new WP_Error( 'report_delivery_write_failed', 'Delivery was saved, but follow-up scan cancellation could not be confirmed.' );
}
@@ -1875,7 +2420,7 @@ function ct_scan_admin_action_handler() {
}
$job_id = isset( $_POST['job_id'] ) ? absint( wp_unslash( $_POST['job_id'] ) ) : 0;
$action = isset( $_POST['ct_scan_operation'] ) && is_string( $_POST['ct_scan_operation'] ) ? sanitize_key( wp_unslash( $_POST['ct_scan_operation'] ) ) : '';
- if ( ! $job_id || ! in_array( $action, array( 'revoke', 'manual_complete', 'send_report', 'record_delivery' ), true ) ) {
+ if ( ! $job_id || ! in_array( $action, array( 'revoke', 'manual_complete', 'send_report', 'record_delivery', 'authority_review' ), true ) ) {
wp_die( esc_html__( 'The requested action is invalid.', 'ct-scan' ), '', array( 'response' => 400 ) );
}
check_admin_referer( 'ct_scan_admin_' . $action . '_' . $job_id );
@@ -1884,6 +2429,7 @@ function ct_scan_admin_action_handler() {
'manual_complete' => 'ct_scan_review_confirm',
'send_report' => 'ct_scan_send_confirm',
'record_delivery' => 'ct_scan_delivery_confirm',
+ 'authority_review' => 'ct_scan_authority_confirm',
);
$confirm_key = $confirmations[ $action ];
if ( ! isset( $_POST[ $confirm_key ] ) || ! is_string( $_POST[ $confirm_key ] ) || '1' !== wp_unslash( $_POST[ $confirm_key ] ) ) {
@@ -1895,6 +2441,8 @@ function ct_scan_admin_action_handler() {
$result = ct_scan_admin_save_manual_remainder( $job_id, isset( $_POST['manual_remainder_notes'] ) && is_string( $_POST['manual_remainder_notes'] ) ? $_POST['manual_remainder_notes'] : '' );
} elseif ( 'send_report' === $action ) {
$result = ct_scan_admin_send_report( $job_id );
+ } elseif ( 'authority_review' === $action ) {
+ $result = ct_scan_admin_record_authority_review( $job_id, isset( $_POST['ct_scan_authority_note'] ) && is_string( $_POST['ct_scan_authority_note'] ) ? wp_unslash( $_POST['ct_scan_authority_note'] ) : '' );
} else {
$result = ct_scan_admin_record_delivery( $job_id );
}
@@ -1910,6 +2458,15 @@ function ct_scan_admin_form( $job_id, $operation, $label, $extra = '' ) {
return '';
}
+/** One line summarizing a recorded manual authority review, for the admin queue. */
+function ct_scan_control_evidence_line( $row ) {
+ $evidence = json_decode( (string) $row->control_evidence, true );
+ if ( ! is_array( $evidence ) ) {
+ return 'unreadable';
+ }
+ return sprintf( '%s at %s UTC, valid until %s UTC: %s', (string) ( $evidence['reviewer'] ?? '?' ), (string) ( $evidence['reviewed_at'] ?? '?' ), (string) ( $evidence['valid_until'] ?? '?' ), (string) ( $evidence['evidence'] ?? '' ) );
+}
+
function ct_scan_admin_result_details( $row ) {
if ( ! is_object( $row ) || 'completed' !== $row->status || ! is_string( $row->result_json ) || '' === $row->result_json ) {
return '';
@@ -1964,7 +2521,7 @@ function ct_scan_admin_page() {
foreach ( $rows as $row ) {
$request_url = admin_url( 'post.php?post=' . (int) $row->request_id . '&action=edit' );
$host = wp_parse_url( $row->target_origin, PHP_URL_HOST );
- $exec = 'Status: ' . $row->status . "\nAttempts: " . (int) $row->attempts . "\nOutcome: " . ( $row->outcome ? $row->outcome : 'pending' ) . "\nError: " . ( $row->error_code ? sanitize_key( $row->error_code ) : '—' ) . "\nChecker revision: " . ( $row->checker_revision ? substr( $row->checker_revision, 0, 12 ) . '…' : '—' );
+ $exec = 'Status: ' . $row->status . "\nAttempts: " . (int) $row->attempts . "\nOutcome: " . ( $row->outcome ? $row->outcome : 'pending' ) . "\nTarget control: " . ( $row->control_required ? ( $row->control_verified_at ? sanitize_key( (string) $row->control_method ) . ' at ' . $row->control_verified_at . ' UTC' : 'required, not shown (' . (int) $row->control_attempts . ' of 3 checks used)' ) : 'not required' ) . "\nError: " . ( $row->error_code ? sanitize_key( $row->error_code ) : '—' ) . ( 'manual_review' === $row->control_method ? "\nAuthority review: " . ct_scan_control_evidence_line( $row ) : '' ) . "\nChecker revision: " . ( $row->checker_revision ? substr( $row->checker_revision, 0, 12 ) . '…' : '—' );
$mail = 'Confirmation email: ' . sanitize_key( $row->confirmation_email_status ) . "\nReport transport: " . sanitize_key( $row->report_notification_status ) . "\nDelivery confirmed: " . ( $row->report_delivered_at ? $row->report_delivered_at . ' UTC' : 'no' ) . "\nRevoked: " . ( $row->revoked_at ? $row->revoked_at . ' UTC' : 'no' );
echo 'Job #' . (int) $row->job_id . ' / request #' . (int) $row->request_id . ' Created ' . esc_html( $row->created_at ) . ' UTC ';
echo '' . esc_html( $row->target_origin ) . ' Terms ' . esc_html( $row->terms_version ) . ' Accepted ' . esc_html( $row->terms_accepted_at ) . ' UTC Expires ' . esc_html( $row->authorization_expires_at ) . ' UTC Scope hash ' . esc_html( substr( $row->scope_hash, 0, 16 ) ) . '… ';
@@ -1987,6 +2544,9 @@ function ct_scan_admin_page() {
echo ct_scan_admin_form( (int) $row->job_id, 'record_delivery', 'Record confirmed delivery', ' I independently confirmed the full report reached the saved contact. ' );
}
}
+ if ( 'awaiting_control' === $row->status && empty( $row->revoked_at ) ) {
+ echo ct_scan_admin_form( (int) $row->job_id, 'authority_review', 'Record authority review', ' I reviewed evidence that this client controls the exact website above. ' );
+ }
if ( empty( $row->revoked_at ) && empty( $row->report_delivered_at ) ) {
echo ct_scan_admin_form( (int) $row->job_id, 'revoke', 'Revoke authorization', ' Revoke this request and block further execution or report sending. ' );
}
diff --git a/integrations/wordpress/tests/control.php b/integrations/wordpress/tests/control.php
new file mode 100644
index 0000000..aac12e3
--- /dev/null
+++ b/integrations/wordpress/tests/control.php
@@ -0,0 +1,365 @@
+ 'strict' ) );
+ $proc = proc_open( array( PHP_BINARY, __FILE__ ), array( 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes, null, $env );
+ echo stream_get_contents( $pipes[1] ), stream_get_contents( $pipes[2] );
+ exit( proc_close( $proc ) );
+}
+require __DIR__ . '/support.php';
+
+global $wpdb;
+$jobs = ct_scan_jobs_table();
+ct_test_purge_fixtures();
+$request_ids = array();
+$mail = array();
+add_filter( 'pre_wp_mail', static function ( $pre, $atts ) use ( &$mail ) {
+ $mail[] = $atts;
+ return true;
+}, 10, 2 );
+
+/** A confirmed job waiting for target control. Returns its ids and the challenge token the customer saw. */
+function ct_test_control_job( $host ) {
+ global $mail, $request_ids, $wpdb;
+ // Scenarios are sequential: end any earlier job still holding a requested or in-progress check.
+ $wpdb->query( "UPDATE " . ct_scan_jobs_table() . " SET status = 'cancelled', error_code = 'test_cleanup', control_state = 'none' WHERE status = 'awaiting_control' AND control_state IN ('requested','checking')" );
+ list( $request_id, $fields ) = ct_test_request( $host );
+ $request_ids[] = $request_id;
+ ct_test_bind( $request_id, $fields ); // strict policy: bound evidence and control are both required
+ $before = count( $mail );
+ $event = ct_scan_on_request_ready( $request_id, $fields );
+ ct_test_assert( is_array( $event ), 'Control fixture job was not created.' );
+ ct_test_assert( 1 === (int) ct_scan_row( $event['job_id'] )->control_required, 'The job did not freeze control_required.' );
+ preg_match( '/token=([a-f0-9]{64})/', $mail[ $before ]['message'], $m );
+ list( $message, $status, $markup ) = ct_scan_confirm_job( $event['job_id'], $m[1] );
+ ct_test_assert( 200 === $status && 1 === preg_match( '/ct-scan-verify=([a-f0-9]{64})/', $markup, $t ) && 1 === preg_match( '/name="ctl" value="([a-f0-9]{64})"/', $markup, $p ), 'Confirmation did not present a challenge and a page capability.' );
+ return array( 'request_id' => $request_id, 'fields' => $fields, 'job_id' => (int) $event['job_id'], 'token' => $t[1], 'page' => $p[1], 'markup' => $markup );
+}
+
+function ct_test_control_claim() {
+ $response = ct_test_rest_call( 'ct_scan_control_claim', '/ct-scan/v1/control/claim', array() );
+ return $response->get_data()['task'] ?? null;
+}
+
+function ct_test_control_admit( $task, $kind = 'control', $override = array() ) {
+ return ct_test_rest_call( 'ct_scan_admit', '/ct-scan/v1/admit', array_merge( array( 'id' => $task['id'], 'lease' => $task['lease'], 'scope_hash' => $task['scope_hash'], 'kind' => $kind ), $override ) );
+}
+
+function ct_test_control_result( $task, $outcome, $observed = array(), $override = array() ) {
+ return ct_test_rest_call( 'ct_scan_control_result', '/ct-scan/v1/control/result', array_merge( array( 'id' => $task['id'], 'lease' => $task['lease'], 'scope_hash' => $task['scope_hash'], 'method' => $task['method'], 'outcome' => $outcome, 'observed' => $observed ), $override ) );
+}
+
+/** Make the previous check old enough that the one-minute interval allows the next. */
+function ct_test_age_control( $job_id ) {
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare( 'UPDATE ' . ct_scan_jobs_table() . ' SET control_last_attempt_ms = control_last_attempt_ms - 61000 WHERE job_id = %d', $job_id ) );
+}
+
+/** One full check: request, claim, admit, result. Returns the result response. */
+function ct_test_run_check( $job, $method, $observed, $outcome = 'observed' ) {
+ global $wpdb;
+ $r = ct_scan_request_control_check( $job['job_id'], $job['page'], $method );
+ ct_test_assert( 'scheduled' === $r, 'A check could not be scheduled: ' . ( is_wp_error( $r ) ? $r->get_error_code() : $r ) );
+ $task = ct_test_control_claim();
+ ct_test_assert( $task && (int) $task['id'] === $job['job_id'], 'The scheduled check was not claimed.' );
+ $grant = ct_test_control_admit( $task );
+ ct_test_assert( 200 === $grant->get_status(), 'The check was not admitted: ' . wp_json_encode( $grant->get_data() ) );
+ return ct_test_control_result( $task, $outcome, $observed );
+}
+
+// ---- Confirmation no longer queues: mailbox control is not target control -----------------------------
+$a = ct_test_control_job( 'control-a.example.net' );
+$row = ct_scan_row( $a['job_id'] );
+ct_test_assert( 'awaiting_control' === $row->status && 'pending' === $row->control_state && ! empty( $row->confirmed_at ) && hash( 'sha256', $a['token'] ) === $row->control_token_hash && hash( 'sha256', $a['page'] ) === $row->control_page_hash, 'Confirmation did not leave the job awaiting target control with hashed secrets.' );
+// Two independent secrets: the published challenge value is not the page capability.
+ct_test_assert( $a['token'] !== $a['page'] && $a['token'] === ct_scan_control_challenge( $row, $a['page'] ) && false === strpos( wp_json_encode( $row ), $a['page'] ), 'The challenge and the page capability are not independent, or the capability is stored.' );
+ct_test_assert( false === strpos( $a['markup'], 'ct-scan-verify=' . $a['page'] ), 'The page capability is shown as the challenge.' );
+ct_test_assert( false === strpos( wp_json_encode( $row ), $a['token'] ), 'The raw challenge token is stored.' );
+ct_test_assert( false !== strpos( $a['markup'], '_ct-scan-challenge.control-a.example.net' ) && false !== strpos( $a['markup'], 'https://control-a.example.net/.well-known/ct-scan-challenge.txt' ), 'The instructions do not name the exact hostname and fixed paths.' );
+ct_test_assert( 'awaiting_control' === get_post_meta( $a['request_id'], '_ct_scan_status', true ), 'The request status did not record the wait.' );
+ct_test_assert( null === ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job'], 'A job awaiting target control was claimable for scanning.' );
+$fake_running = array( 'job' => array( 'id' => $a['job_id'], 'lease' => str_repeat( '1', 64 ), 'scope_hash' => $row->scope_hash ) );
+ct_test_assert( 410 === ct_test_admit( $fake_running, 'http' )->get_status(), 'A job awaiting target control was admitted for scanning.' );
+ct_test_assert( 410 === ct_test_rest_call( 'ct_scan_start', '/ct-scan/v1/start', array( 'id' => $a['job_id'], 'lease' => str_repeat( '1', 64 ), 'scope_hash' => $row->scope_hash ) )->get_status(), 'A job awaiting target control could be started.' );
+
+// A legacy job (control not required) is unchanged: confirmation queues it.
+list( $legacy_id, $legacy_fields ) = ct_test_request( 'control-legacy.example.net' );
+$request_ids[] = $legacy_id;
+ct_test_bind( $legacy_id, $legacy_fields );
+$before = count( $mail );
+$legacy = ct_scan_on_request_ready( $legacy_id, $legacy_fields );
+$wpdb->update( $jobs, array( 'control_required' => 0 ), array( 'job_id' => $legacy['job_id'] ) );
+preg_match( '/token=([a-f0-9]{64})/', $mail[ $before ]['message'], $m );
+list( , $legacy_status ) = ct_scan_confirm_job( $legacy['job_id'], $m[1] );
+ct_test_assert( 200 === $legacy_status && 'queued' === ct_scan_row( $legacy['job_id'] )->status, 'A job that does not require target control was not queued by confirmation.' );
+$wpdb->update( $jobs, array( 'status' => 'cancelled', 'error_code' => 'test_cleanup' ), array( 'job_id' => $legacy['job_id'] ) );
+
+// ---- Scheduling a check: customer-driven, bound to the secret, limited ------------------------------
+ct_test_assert( is_wp_error( ct_scan_request_control_check( $a['job_id'], str_repeat( 'a', 64 ), 'dns_txt' ) ), 'A wrong page capability scheduled a check.' );
+// Anyone can read the published challenge; it must not let them use up the customer's checks.
+ct_test_assert( 'control_link_invalid' === ct_scan_request_control_check( $a['job_id'], $a['token'], 'dns_txt' )->get_error_code() && 'pending' === ct_scan_row( $a['job_id'] )->control_state && 0 === (int) ct_scan_row( $a['job_id'] )->control_attempts, 'The published challenge value scheduled a check.' );
+ct_test_assert( 'control_method_invalid' === ct_scan_request_control_check( $a['job_id'], $a['page'], 'http' )->get_error_code(), 'An unsupported method was accepted.' );
+ct_test_assert( 'pending' === ct_scan_row( $a['job_id'] )->control_state, 'A rejected request changed the state.' );
+ct_test_assert( 'scheduled' === ct_scan_request_control_check( $a['job_id'], $a['page'], 'dns_txt' ), 'A valid check was not scheduled.' );
+ct_test_assert( 'control_check_in_progress' === ct_scan_request_control_check( $a['job_id'], $a['page'], 'https_file' )->get_error_code(), 'A second check was scheduled while one is outstanding.' );
+ct_test_assert( 0 === (int) ct_scan_row( $a['job_id'] )->control_attempts, 'Scheduling consumed an attempt (only an admission may).' );
+
+// ---- Claim and admission -----------------------------------------------------------------------------
+$task = ct_test_control_claim();
+ct_test_assert( $task && 'dns_txt' === $task['method'] && (int) $task['id'] === $a['job_id'] && hash( 'sha256', 'https://control-a.example.net/' ) === $task['scope_hash'], 'The control task does not name the exact target and chosen method.' );
+ct_test_assert( null === ct_test_control_claim(), 'A checking job was claimed twice.' );
+ct_test_assert( 410 === ct_test_control_admit( $task, 'control', array( 'lease' => str_repeat( 'c', 64 ) ) )->get_status(), 'A control admission with a foreign lease was granted.' );
+ct_test_assert( 410 === ct_test_control_admit( $task, 'control', array( 'scope_hash' => str_repeat( 'd', 64 ) ) )->get_status(), 'A control admission with a foreign scope was granted.' );
+ct_test_assert( 200 === ct_test_control_admit( $task, 'control_probe' )->get_status() && 0 === (int) ct_scan_row( $a['job_id'] )->control_attempts, 'A probe consumed an attempt or failed.' );
+// Without an admission the worker cannot get a result recorded.
+$noadm = ct_test_control_result( $task, 'observed', array( 'ct-scan-verify=' . $a['token'] ) );
+ct_test_assert( 409 === $noadm->get_status() && 'awaiting_control' === ct_scan_row( $a['job_id'] )->status, 'A result was accepted for a check that was never admitted.' );
+$grant = ct_test_control_admit( $task );
+$gd = $grant->get_data();
+ct_test_assert( 200 === $grant->get_status() && 1 === $gd['sequence'] && 1000 === $gd['valid_ms'] && 10000 === $gd['timeout_ms'] && 2 === $gd['http_remaining'] && 1 === (int) ct_scan_row( $a['job_id'] )->control_attempts, 'The first control admission was not granted with the disclosed bounds.' );
+$again = ct_test_control_admit( $task );
+ct_test_assert( 409 === $again->get_status() && 'already_admitted' === $again->get_data()['reason'] && 1 === (int) ct_scan_row( $a['job_id'] )->control_attempts, 'A second control request under the same lease was admitted.' );
+// The lease (two minutes) outlives the one-minute interval: once the interval has passed, the same
+// lease is still refused and the customer's remaining checks are untouched.
+$wpdb->query( $wpdb->prepare( "UPDATE {$jobs} SET control_last_attempt_ms = control_last_attempt_ms - 61000, control_leased_ms = control_leased_ms - 61000 WHERE job_id = %d", $a['job_id'] ) );
+$late = ct_test_control_admit( $task );
+$row = ct_scan_row( $a['job_id'] );
+ct_test_assert( 409 === $late->get_status() && 'already_admitted' === $late->get_data()['reason'] && 1 === (int) $row->control_attempts && 'checking' === $row->control_state, 'A lease was admitted twice once the one-minute interval had passed.' );
+$wpdb->query( $wpdb->prepare( "UPDATE {$jobs} SET control_last_attempt_ms = control_last_attempt_ms + 61000, control_leased_ms = control_leased_ms + 61000 WHERE job_id = %d", $a['job_id'] ) );
+// A forged / wrong-method / malformed result is rejected and keeps the check open.
+foreach ( array(
+ array( 'observed', array( 'ct-scan-verify=' . $a['token'] ), array( 'method' => 'https_file' ), 410 ),
+ array( 'observed', array( 'ct-scan-verify=' . $a['token'] ), array( 'lease' => str_repeat( 'e', 64 ) ), 410 ),
+ array( 'observed', 'ct-scan-verify=' . $a['token'], array(), 400 ),
+ array( 'observed', array_fill( 0, 9, 'x' ), array(), 400 ),
+ array( 'observed', array( str_repeat( 'x', 4097 ) ), array(), 400 ),
+ array( 'observed', array( str_repeat( 'x', 2500 ), str_repeat( 'y', 2500 ) ), array(), 400 ),
+ array( 'observed', array( 5 ), array(), 400 ),
+ array( 'not_found', array( 'ct-scan-verify=' . $a['token'] ), array(), 400 ),
+ array( 'verified', array(), array(), 400 ),
+) as $i => $case ) {
+ $bad = ct_test_control_result( $task, $case[0], $case[1], $case[2] );
+ ct_test_assert( $case[3] === $bad->get_status() && 'awaiting_control' === ct_scan_row( $a['job_id'] )->status, "Malformed result case $i was accepted ({$bad->get_status()})." );
+}
+// A value that is not the challenge does not verify, however close.
+$token = $a['token'];
+foreach ( array(
+ 'ct-scan-verify=' . strtoupper( $token ), 'ct-scan-verify=' . substr( $token, 0, 63 ), 'ct-scan-verify=' . $token . 'x', ' ct-scan-verify=' . $token,
+ 'ct-scan-verify= ' . $token, $token, 'ct-scan-verify=' . str_repeat( 'a', 64 ), 'ct-scan-verify=' . "\0" . $token,
+) as $near ) {
+ ct_test_assert( ! ct_scan_control_value_matches( 'dns_txt', $near, hash( 'sha256', $token ) ), 'A near-miss DNS value verified: ' . bin2hex( substr( $near, 0, 20 ) ) );
+}
+ct_test_assert( ct_scan_control_value_matches( 'dns_txt', 'ct-scan-verify=' . $token, hash( 'sha256', $token ) ), 'The exact DNS value did not verify.' );
+foreach ( array( $token, $token . "\n", $token . "\r\n", " \t" . $token, "\xEF\xBB\xBF" . $token . "\n" ) as $ok ) {
+ ct_test_assert( ct_scan_control_value_matches( 'https_file', $ok, hash( 'sha256', $token ) ), 'The file challenge did not verify with harmless whitespace.' );
+}
+foreach ( array( 'prefix ' . $token, $token . ' suffix', strtoupper( $token ), 'ct-scan-verify=' . $token, '', "\xEF\xBB\xBF\xEF\xBB\xBF" . $token, $token . "\xEF\xBB\xBF" ) as $near ) {
+ ct_test_assert( ! ct_scan_control_value_matches( 'https_file', $near, hash( 'sha256', $token ) ), 'A near-miss file value verified.' );
+}
+// First honest failure: the DNS record is not there yet. The check is used, the job keeps waiting.
+$fail1 = ct_test_control_result( $task, 'not_found' );
+$row = ct_scan_row( $a['job_id'] );
+ct_test_assert( 200 === $fail1->get_status() && false === $fail1->get_data()['verified'] && 'awaiting_control' === $row->status && 'pending' === $row->control_state && 1 === (int) $row->control_attempts && empty( $row->control_lease_hash ), 'A failed check did not return the job to waiting with the attempt spent.' );
+ct_test_assert( 'control_too_soon' === ct_scan_request_control_check( $a['job_id'], $a['page'], 'dns_txt' )->get_error_code(), 'A check was scheduled inside the one-minute interval.' );
+ct_test_assert( null === ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job'], 'A failed check made the job claimable.' );
+ct_test_assert( 409 === ct_test_control_result( $task, 'observed', array( 'ct-scan-verify=' . $a['token'] ) )->get_status() || 410 === ct_test_control_result( $task, 'observed', array( 'ct-scan-verify=' . $a['token'] ) )->get_status(), 'A spent task accepted another result.' );
+
+// Second check by file, with the right value: verified, evidence stores only a hash, job becomes claimable.
+ct_test_age_control( $a['job_id'] );
+$ok = ct_test_run_check( $a, 'https_file', array( $a['token'] . "\n" ) );
+$row = ct_scan_row( $a['job_id'] );
+$evidence = json_decode( $row->control_evidence, true );
+ct_test_assert( 200 === $ok->get_status() && true === $ok->get_data()['verified'] && 'queued' === $row->status && 'https_file' === $row->control_method && ! empty( $row->control_verified_at ) && null === $row->control_token_hash && 2 === (int) $row->control_attempts, 'A correct file challenge did not verify the target.' );
+ct_test_assert( 'https_file' === $evidence['method'] && 2 === $evidence['attempt'] && $evidence['target'] === $a['fields']['target'] && hash( 'sha256', $a['token'] . "\n" ) === $evidence['observed_sha256'] && false === strpos( $row->control_evidence, $a['token'] ), 'The verification evidence is wrong or contains the secret.' );
+$claimed = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job'];
+ct_test_assert( is_array( $claimed ) && (int) $claimed['id'] === $a['job_id'], 'A verified job was not claimable.' );
+$started = ct_test_rest_call( 'ct_scan_start', '/ct-scan/v1/start', array( 'id' => $claimed['id'], 'lease' => $claimed['lease'], 'scope_hash' => $claimed['scope_hash'] ) );
+ct_test_assert( 200 === $started->get_status(), 'A verified job could not start.' );
+ct_test_assert( 200 === ct_test_admit( array( 'job' => $claimed ) )->get_status(), 'A verified, running job was not admitted.' );
+// Replaying the control token after success does nothing.
+ct_test_assert( is_wp_error( ct_scan_request_control_check( $a['job_id'], $a['page'], 'dns_txt' ) ), 'A spent challenge scheduled another check.' );
+ct_test_end_job( $a['job_id'] );
+
+// ---- Three failures end in `blocked`, never in a scan ------------------------------------------------
+$b = ct_test_control_job( 'control-b.example.net' );
+for ( $i = 1; $i <= 3; $i++ ) {
+ if ( $i > 1 ) {
+ ct_test_age_control( $b['job_id'] );
+ }
+ $res = ct_test_run_check( $b, 1 === $i % 2 ? 'dns_txt' : 'https_file', array( 'unrelated-text' ) );
+ ct_test_assert( 200 === $res->get_status() && false === $res->get_data()['verified'], "Failed check $i was reported verified." );
+}
+$row = ct_scan_row( $b['job_id'] );
+ct_test_assert( 'blocked' === $row->status && 'target_control_failed' === $row->error_code && 3 === (int) $row->control_attempts && null === $row->control_token_hash && ! empty( $row->finished_at ), 'Three failed checks did not block the job.' );
+ct_test_assert( 'blocked' === get_post_meta( $b['request_id'], '_ct_scan_status', true ), 'The request did not record the blocked state.' );
+ct_test_assert( is_wp_error( ct_scan_request_control_check( $b['job_id'], $b['page'], 'dns_txt' ) ), 'A blocked job accepted another check.' );
+ct_test_assert( null === ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job'], 'A blocked job was claimed for scanning.' );
+ct_test_assert( 410 === ct_test_admit( array( 'job' => array( 'id' => $b['job_id'], 'lease' => str_repeat( '2', 64 ), 'scope_hash' => $row->scope_hash ) ), 'http' )->get_status(), 'A blocked job was admitted for scanning.' );
+// Even with operator notes, a blocked job gets no detailed report.
+$wpdb->update( $jobs, array( 'manual_remainder_status' => 'complete', 'manual_remainder_notes' => 'Operator notes.' ), array( 'job_id' => $b['job_id'] ) );
+$mail_before_report = count( $mail );
+$report = ct_scan_admin_send_report( $b['job_id'] );
+ct_test_assert( is_wp_error( $report ) && 'report_target_unverified' === $report->get_error_code(), 'A detailed report path opened for a target whose control was never shown.' );
+ct_test_assert( count( $mail ) === $mail_before_report, 'Mail was sent for a blocked job.' );
+
+// ---- Lapsed lease, customer stop, expiry --------------------------------------------------------------
+$c = ct_test_control_job( 'control-c.example.net' );
+ct_scan_request_control_check( $c['job_id'], $c['page'], 'dns_txt' );
+$task_c = ct_test_control_claim();
+ct_test_assert( 200 === ct_test_control_admit( $task_c )->get_status(), 'Fixture admission failed.' );
+$wpdb->update( $jobs, array( 'control_lease_until' => gmdate( 'Y-m-d H:i:s', time() - 5 ) ), array( 'job_id' => $c['job_id'] ) );
+ct_test_assert( 410 === ct_test_control_result( $task_c, 'observed', array( 'ct-scan-verify=' . $c['token'] ) )->get_status(), 'A result after the lease lapsed was accepted.' );
+$row = ct_scan_row( $c['job_id'] );
+ct_test_assert( 'awaiting_control' === $row->status && 'pending' === $row->control_state && 1 === (int) $row->control_attempts, 'A lapsed lease refunded the attempt or stranded the job.' );
+// Stop while a check is scheduled: nothing more is admitted or recorded.
+ct_test_age_control( $c['job_id'] );
+ct_scan_request_control_check( $c['job_id'], $c['page'], 'dns_txt' );
+$task_c2 = ct_test_control_claim();
+$stop_token = bin2hex( random_bytes( 32 ) );
+$wpdb->update( $jobs, array( 'stop_token_hash' => hash( 'sha256', $stop_token ) ), array( 'job_id' => $c['job_id'] ) );
+ct_test_assert( 'stopped' === ct_scan_customer_stop( $c['job_id'], $stop_token ), 'Customer stop failed during target control.' );
+$after = ct_scan_row( $c['job_id'] );
+ct_test_assert( 'cancelled' === $after->status && null === $after->control_token_hash && 'none' === $after->control_state, 'Stop did not clear the control state.' );
+ct_test_age_control( $c['job_id'] );
+ct_test_assert( 410 === ct_test_control_admit( $task_c2 )->get_status() && 410 === ct_test_control_admit( $task_c2, 'control_probe' )->get_status(), 'A control check was admitted after Stop.' );
+ct_test_assert( 410 === ct_test_control_result( $task_c2, 'observed', array( 'ct-scan-verify=' . $c['token'] ) )->get_status(), 'A result was accepted after Stop.' );
+
+$d = ct_test_control_job( 'control-d.example.net' );
+ct_scan_request_control_check( $d['job_id'], $d['page'], 'https_file' );
+$task_d = ct_test_control_claim();
+$wpdb->update( $jobs, array( 'authorization_expires_at' => gmdate( 'Y-m-d H:i:s', time() - 1 ) ), array( 'job_id' => $d['job_id'] ) );
+$exp = ct_test_control_admit( $task_d );
+ct_test_assert( 410 === $exp->get_status() && 'job_expired' === $exp->get_data()['reason'] && 'expired' === ct_scan_row( $d['job_id'] )->status && 0 === (int) ct_scan_row( $d['job_id'] )->control_attempts, 'A control check was admitted after authorization expiry.' );
+
+// Fault: a thrown exception or a lost compare-and-set refuses.
+$e = ct_test_control_job( 'control-e.example.net' );
+ct_scan_request_control_check( $e['job_id'], $e['page'], 'dns_txt' );
+$task_e = ct_test_control_claim();
+$racer = static function ( $sql ) use ( &$raced, $jobs, $e ) {
+ global $wpdb;
+ if ( ! $raced && 0 === strpos( ltrim( $sql ), "UPDATE `{$jobs}` SET `control_attempts`" ) ) {
+ $raced = true;
+ $wpdb->query( $wpdb->prepare( "UPDATE {$jobs} SET control_attempts = 2 WHERE job_id = %d", $e['job_id'] ) );
+ }
+ return $sql;
+};
+$raced = false;
+add_filter( 'query', $racer );
+$lost = ct_test_control_admit( $task_e );
+remove_filter( 'query', $racer );
+ct_test_assert( $raced && 503 === $lost->get_status() && 'admission_write_failed' === $lost->get_data()['reason'] && 2 === (int) ct_scan_row( $e['job_id'] )->control_attempts, 'A lost control reservation race was granted or overwritten.' );
+$wpdb->update( $jobs, array( 'control_attempts' => 0, 'control_last_attempt_ms' => null ), array( 'job_id' => $e['job_id'] ) );
+$thrower = static function ( $sql ) use ( $jobs ) {
+ if ( 0 === strpos( ltrim( $sql ), "UPDATE `{$jobs}` SET `control_attempts`" ) ) {
+ throw new RuntimeException( 'injected database fault' );
+ }
+ return $sql;
+};
+add_filter( 'query', $thrower );
+$faulted = ct_test_control_admit( $task_e );
+remove_filter( 'query', $thrower );
+ct_test_assert( 503 === $faulted->get_status() && 0 === (int) ct_scan_row( $e['job_id'] )->control_attempts, 'A database fault during control admission did not fail closed.' );
+ct_test_assert( ct_scan_lock_acquire(), 'The queue lock leaked after a faulted control admission.' );
+ct_scan_lock_release();
+
+// ---- Cases that defend the central invariants directly (not only through the happy path) ----------------------
+// A control-required job forced into `queued` without verified control is never claimed, and is cancelled.
+$g = ct_test_control_job( 'control-g.example.net' );
+$wpdb->update( $jobs, array( 'status' => 'queued', 'control_state' => 'none' ), array( 'job_id' => $g['job_id'] ) );
+ct_test_assert( null === ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job'] && 'cancelled' === ct_scan_row( $g['job_id'] )->status, 'A control-required job without verified control was claimable.' );
+// ...and a leased/running one is refused at start and admission.
+$h = ct_test_control_job( 'control-h.example.net' );
+$wpdb->update( $jobs, array( 'status' => 'running', 'attempts' => 1, 'lease_hash' => hash( 'sha256', str_repeat( '3', 64 ) ), 'lease_until' => gmdate( 'Y-m-d H:i:s', time() + 200 ) ), array( 'job_id' => $h['job_id'] ) );
+$bad_admit = ct_test_admit( array( 'job' => array( 'id' => $h['job_id'], 'lease' => str_repeat( '3', 64 ), 'scope_hash' => ct_scan_row( $h['job_id'] )->scope_hash ) ) );
+ct_test_assert( 410 === $bad_admit->get_status() && 'authorization_revoked_or_expired' === $bad_admit->get_data()['reason'], 'A running, unverified, control-required job was admitted.' );
+// The fourth admission is refused even with a valid lease and interval.
+$i = ct_test_control_job( 'control-i.example.net' );
+ct_scan_request_control_check( $i['job_id'], $i['page'], 'dns_txt' );
+$task_i = ct_test_control_claim();
+$wpdb->update( $jobs, array( 'control_attempts' => 3, 'control_last_attempt_ms' => 1 ), array( 'job_id' => $i['job_id'] ) );
+$over = ct_test_control_admit( $task_i );
+ct_test_assert( 409 === $over->get_status() && 'budget_exhausted' === $over->get_data()['reason'] && 3 === (int) ct_scan_row( $i['job_id'] )->control_attempts, 'A fourth control admission was granted.' );
+// Every check spent and the last result lost: maintenance blocks the job instead of leaving it waiting.
+$wpdb->update( $jobs, array( 'control_lease_until' => gmdate( 'Y-m-d H:i:s', time() - 5 ) ), array( 'job_id' => $i['job_id'] ) );
+ct_scan_maintenance_locked();
+$ri = ct_scan_row( $i['job_id'] );
+ct_test_assert( 'blocked' === $ri->status && 'target_control_failed' === $ri->error_code && null === $ri->control_page_hash, 'An exhausted job whose last result was lost was left waiting.' );
+// An unconfirmed row is not admitted for a control check.
+$j = ct_test_control_job( 'control-j.example.net' );
+ct_scan_request_control_check( $j['job_id'], $j['page'], 'dns_txt' );
+$task_j = ct_test_control_claim();
+$wpdb->update( $jobs, array( 'confirmed_at' => null ), array( 'job_id' => $j['job_id'] ) );
+$unconfirmed = ct_test_control_admit( $task_j );
+ct_test_assert( 410 === $unconfirmed->get_status() && 'recipient_unconfirmed' === $unconfirmed->get_data()['reason'], 'An unconfirmed row was admitted for a control check.' );
+// A revoked job's page is dead.
+$k = ct_test_control_job( 'control-k.example.net' );
+wp_set_current_user( 1 );
+ct_scan_admin_revoke( $k['job_id'] );
+ct_test_assert( 'control_link_invalid' === ct_scan_request_control_check( $k['job_id'], $k['page'], 'dns_txt' )->get_error_code(), 'A revoked job still accepted a control request.' );
+// A claim whose recorded method is not one of the two is cancelled, not run.
+$l = ct_test_control_job( 'control-l.example.net' );
+$wpdb->update( $jobs, array( 'control_state' => 'requested', 'control_requested_method' => 'ftp' ), array( 'job_id' => $l['job_id'] ) );
+ct_test_assert( null === ct_test_control_claim() && 'cancelled' === ct_scan_row( $l['job_id'] )->status, 'A control check with an unknown method was handed to the worker.' );
+// One piece of target traffic at a time: a check in progress blocks scan claims and a scan blocks check claims.
+$m1 = ct_test_control_job( 'control-m1.example.net' );
+ct_scan_request_control_check( $m1['job_id'], $m1['page'], 'dns_txt' );
+$task_m = ct_test_control_claim();
+list( $q_id, $q_fields ) = ct_test_request( 'control-m2.example.net' );
+$request_ids[] = $q_id;
+ct_test_bind( $q_id, $q_fields );
+$q_event = ct_scan_on_request_ready( $q_id, $q_fields );
+$wpdb->update( $jobs, array( 'control_required' => 0 ), array( 'job_id' => $q_event['job_id'] ) );
+ct_test_queue_job( $q_event['job_id'] );
+ct_test_assert( null === ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job'], 'A scan was claimed while a control check was in progress.' );
+$wpdb->update( $jobs, array( 'control_state' => 'pending', 'control_lease_hash' => null ), array( 'job_id' => $m1['job_id'] ) );
+ct_scan_request_control_check( $m1['job_id'], $m1['page'], 'dns_txt' );
+$scan_claim = ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job'];
+ct_test_assert( is_array( $scan_claim ) && (int) $scan_claim['id'] === (int) $q_event['job_id'], 'The queued scan was not claimed.' );
+ct_test_assert( null === ct_test_control_claim(), 'A control check was claimed while a scan was leased.' );
+
+// ---- Recorded manual authority review ------------------------------------------------------------------
+// Not while a check is in flight: its result could otherwise land on a job already released by review.
+$wpdb->update( $jobs, array( 'status' => 'cancelled', 'error_code' => 'test_cleanup' ), array( 'job_id' => (int) $scan_claim['id'] ) );
+$n = ct_test_control_job( 'control-n.example.net' );
+ct_scan_request_control_check( $n['job_id'], $n['page'], 'dns_txt' );
+$task_n = ct_test_control_claim();
+ct_test_assert( $task_n && (int) $task_n['id'] === $n['job_id'], 'Fixture control claim failed.' );
+wp_set_current_user( 1 );
+$busy_review = ct_scan_admin_record_authority_review( $n['job_id'], 'Reviewed the signed engagement letter naming this website.' );
+$rn = ct_scan_row( $n['job_id'] );
+ct_test_assert( is_wp_error( $busy_review ) && 'authority_review_check_in_progress' === $busy_review->get_error_code() && 'awaiting_control' === $rn->status && 'checking' === $rn->control_state && null === $rn->control_verified_at, 'An authority review was recorded while a control check was in flight.' );
+$wpdb->update( $jobs, array( 'status' => 'cancelled', 'error_code' => 'test_cleanup', 'control_state' => 'none' ), array( 'job_id' => $n['job_id'] ) );
+
+$f = ct_test_control_job( 'control-f.example.net' );
+wp_set_current_user( 0 );
+ct_test_assert( 'authority_review_forbidden' === ct_scan_admin_record_authority_review( $f['job_id'], str_repeat( 'Reviewed the signed engagement letter. ', 2 ) )->get_error_code(), 'An anonymous caller recorded an authority review.' );
+wp_set_current_user( 1 );
+ct_test_assert( 'authority_review_evidence_required' === ct_scan_admin_record_authority_review( $f['job_id'], 'too short' )->get_error_code(), 'A review without evidence was recorded.' );
+ct_test_assert( 'authority_review_evidence_required' === ct_scan_admin_record_authority_review( $f['job_id'], str_repeat( 'x', 1001 ) )->get_error_code(), 'An oversized review note was recorded.' );
+ct_test_assert( 'awaiting_control' === ct_scan_row( $f['job_id'] )->status, 'A rejected review changed the job.' );
+ct_test_assert( true === ct_scan_admin_record_authority_review( $f['job_id'], 'Reviewed the signed engagement letter naming this website.' ), 'A valid authority review was refused.' );
+$row = ct_scan_row( $f['job_id'] );
+$ev = json_decode( $row->control_evidence, true );
+ct_test_assert( 'queued' === $row->status && 'manual_review' === $row->control_method && ! empty( $row->control_verified_at ) && $row->control_valid_until === $row->authorization_expires_at, 'The review did not release the job with an expiry.' );
+ct_test_assert( 1 === $ev['reviewer_id'] && 'fixture_admin' === $ev['reviewer'] && $ev['target'] === $f['fields']['target'] && 'Reviewed the signed engagement letter naming this website.' === $ev['evidence'] && ! empty( $ev['reviewed_at'] ) && $ev['valid_until'] === $row->authorization_expires_at, 'The review record omits reviewer, evidence, target, time or expiry.' );
+ct_test_assert( is_wp_error( ct_scan_admin_record_authority_review( $f['job_id'], 'A second review of the same job should not be possible.' ) ), 'A queued job accepted another review.' );
+// An expired review ends the authorization.
+$wpdb->update( $jobs, array( 'control_valid_until' => gmdate( 'Y-m-d H:i:s', time() - 1 ) ), array( 'job_id' => $f['job_id'] ) );
+ct_test_assert( null === ct_test_rest_call( 'ct_scan_claim', '/ct-scan/v1/claim', array() )->get_data()['job'] && 'cancelled' === ct_scan_row( $f['job_id'] )->status, 'An expired authority review still allowed a claim.' );
+// A job that never required control cannot be reviewed into the control path.
+$wpdb->update( $jobs, array( 'status' => 'awaiting_control', 'control_required' => 0 ), array( 'job_id' => $f['job_id'] ) );
+ct_test_assert( is_wp_error( ct_scan_admin_record_authority_review( $f['job_id'], 'Reviewing a job that does not require control should fail.' ) ), 'A review was recorded for a job that does not require control.' );
+
+// A row without the control_required value (an incomplete upgrade) is treated as requiring control.
+$missing = (object) array( 'control_verified_at' => null );
+ct_test_assert( ct_scan_control_required( $missing ) && ct_scan_control_required( (object) array( 'control_required' => null ) ) && ! ct_scan_control_required( (object) array( 'control_required' => '0' ) ), 'A missing control_required value was treated as not required.' );
+
+foreach ( $request_ids as $id ) {
+ $wpdb->delete( $jobs, array( 'request_id' => $id ), array( '%d' ) );
+ wp_delete_post( $id, true );
+}
+echo "Target control checks passed.\n";
diff --git a/integrations/wordpress/tests/evidence-strict.php b/integrations/wordpress/tests/evidence-strict.php
index 63f248a..2b481ac 100644
--- a/integrations/wordpress/tests/evidence-strict.php
+++ b/integrations/wordpress/tests/evidence-strict.php
@@ -82,6 +82,17 @@
$count = count( $mail );
ct_test_assert( 'sent' === ct_scan_send_accepted_copy( $upper_event['job_id'] ) && count( $mail ) === $count + 1, 'An upper-case recorded terms hash blocked the accepted copy.' );
+// The strict policy requires target control: confirmation leaves the job awaiting control and still sends the copy.
+list( $ctl_id, $ctl_fields ) = ct_test_request( 'strict-awaiting-control.example.net', 'control.customer@example.com' );
+$request_ids[] = $ctl_id;
+ct_test_bind( $ctl_id, $ctl_fields );
+$count = count( $mail );
+$ctl_event = ct_scan_on_request_ready( $ctl_id, $ctl_fields );
+ct_test_assert( is_array( $ctl_event ) && 1 === (int) ct_scan_row( $ctl_event['job_id'] )->control_required && 1 === preg_match( '/token=([a-f0-9]{64})/', $mail[ $count ]['message'], $ctl_m ), 'The control-required fixture job or its confirmation mail is missing.' );
+list( , $ctl_status ) = ct_scan_confirm_job( $ctl_event['job_id'], $ctl_m[1] );
+$ctl_row = ct_scan_row( $ctl_event['job_id'] );
+ct_test_assert( 200 === $ctl_status && 'awaiting_control' === $ctl_row->status && 'accepted' === $ctl_row->accepted_copy_status && array( 'control.customer@example.com' ) === array_values( (array) $mail[ count( $mail ) - 1 ]['to'] ), 'Confirming a job that awaits target control did not send the accepted copy.' );
+
// A job that is no longer live when the copy would be sent gets none, and its copy state is untouched.
foreach ( array( 'cancelled', 'revoked', 'evidence_changed' ) as $how ) {
list( $dead_id, $dead_fields ) = ct_test_request( 'strict-dead-' . str_replace( '_', '-', $how ) . '.example.net' );
diff --git a/integrations/wordpress/tests/evidence.php b/integrations/wordpress/tests/evidence.php
index debb57e..41a3eb3 100644
--- a/integrations/wordpress/tests/evidence.php
+++ b/integrations/wordpress/tests/evidence.php
@@ -28,6 +28,7 @@ function ct_test_refused_with( $request_id, $fields, $code, $message ) {
$request_ids[] = $legacy_id;
$legacy = ct_scan_on_request_ready( $legacy_id, $legacy_fields );
ct_test_assert( is_array( $legacy ) && null === ct_scan_row( $legacy['job_id'] )->summary_sha256, 'A legacy acceptance was refused or gained a summary digest.' );
+ct_test_assert( 0 === (int) ct_scan_row( $legacy['job_id'] )->bound_required && 0 === (int) ct_scan_row( $legacy['job_id'] )->control_required, 'An explicitly grandfathered r2 job was made to require evidence or control.' );
// A valid bound acceptance creates a job that records the summary digest.
list( $ok_id, $ok_fields ) = ct_test_request( 'evidence-ok.example.net' );
diff --git a/integrations/wordpress/tests/integration.php b/integrations/wordpress/tests/integration.php
index c03ca93..5320b55 100644
--- a/integrations/wordpress/tests/integration.php
+++ b/integrations/wordpress/tests/integration.php
@@ -158,6 +158,17 @@
ct_test_assert( true === ct_scan_admin_record_delivery( $unknown_event['job_id'] ), 'Independent proof must allow recording delivery from unknown state.' );
$mail_mode = true;
+// The recorded terms hash is compared case-insensitively at report time (the job stores it lowercased).
+list( $upper_id, $upper_fields ) = ct_test_request( 'upper-hash-report-fixture.example.net' );
+$request_ids[] = $upper_id;
+update_post_meta( $upper_id, '_ct_terms_sha256', strtoupper( get_post_meta( $upper_id, '_ct_terms_sha256', true ) ) );
+$upper_event = ct_scan_on_request_ready( $upper_id, $upper_fields );
+ct_test_assert( is_array( $upper_event ), 'A request with an upper-case terms hash was refused.' );
+ct_test_queue_job( $upper_event['job_id'] );
+$wpdb->update( $jobs, array( 'status' => 'failed', 'finished_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $upper_event['job_id'] ) );
+ct_test_assert( true === ct_scan_admin_save_manual_remainder( $upper_event['job_id'], 'No remaining work was performed in this fixture.' ), 'Upper-case hash fixture manual notes failed.' );
+ct_test_assert( true === ct_scan_admin_send_report( $upper_event['job_id'] ), 'An upper-case recorded terms hash blocked the reviewed report.' );
+
// Old requests are never backfilled, and any changed saved target permanently cancels authorization.
list( $legacy_id, $legacy_fields ) = ct_test_request( 'legacy-fixture.example.net' );
$request_ids[] = $legacy_id;
diff --git a/integrations/wordpress/tests/support.php b/integrations/wordpress/tests/support.php
index 4de521e..668c6cc 100644
--- a/integrations/wordpress/tests/support.php
+++ b/integrations/wordpress/tests/support.php
@@ -94,7 +94,16 @@ function ct_test_result( $target ) {
function ct_test_queue_job( $job_id ) {
global $wpdb;
- $wpdb->update( ct_scan_jobs_table(), array( 'status' => 'queued', 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, 'confirmed_at' => ct_scan_utc_now() ), array( 'job_id' => (int) $job_id ) );
+ $update = array( 'status' => 'queued', 'confirmation_token_hash' => null, 'confirmation_expires_at' => null, 'confirmed_at' => ct_scan_utc_now() );
+ $row = ct_scan_row( $job_id );
+ if ( $row && ! empty( $row->control_required ) ) {
+ // A job that must show control of its target is released only by that proof; fixtures that are not
+ // about the control gate stand in for it.
+ $update['control_verified_at'] = ct_scan_utc_now();
+ $update['control_valid_until'] = $row->authorization_expires_at;
+ $update['control_method'] = 'manual_review';
+ }
+ $wpdb->update( ct_scan_jobs_table(), $update, array( 'job_id' => (int) $job_id ) );
}
diff --git a/integrations/worker/admission.py b/integrations/worker/admission.py
index fedd217..c6a92c6 100644
--- a/integrations/worker/admission.py
+++ b/integrations/worker/admission.py
@@ -116,9 +116,12 @@ def parse_refusal(error: APIError) -> AdmissionDenied:
class AdmissionGate:
"""Supervisor-side client for the WordPress admission route."""
- def __init__(self, api, claim, *, stop_event=None, deadline=None, clock=time.time, monotonic=time.monotonic):
+ def __init__(self, api, claim, *, kind="http", stop_event=None, deadline=None, clock=time.time, monotonic=time.monotonic):
+ if kind not in {"http", "control"}:
+ raise ValueError("invalid_admission_kind")
self.api = api
self.claim = claim
+ self.kind = kind
self.stop_event = stop_event
# Wall-clock (epoch) time after which no further admission is requested at all.
self.deadline = deadline
@@ -177,7 +180,7 @@ def admit(self) -> Grant:
self._check_local()
started = self._monotonic()
try:
- grant = replace(parse_grant(self._call("http")), issued_at=started)
+ grant = replace(parse_grant(self._call(self.kind)), issued_at=started)
if self._monotonic() - started > MAX_GRANT_AGE_SECONDS:
# WordPress answered, but too slowly for the one-second lifetime to be useful.
stale += 1
@@ -202,11 +205,12 @@ def probe(self):
A busy queue lock is retried briefly within one call budget; if it stays
busy the probe reports ``queue_busy``, which the pump treats as inconclusive.
"""
+ probe_kind = "probe" if self.kind == "http" else "control_probe"
busy_until = self._monotonic() + CALL_BUDGET_SECONDS
while True:
self._check_local()
try:
- response = self._call("probe")
+ response = self._call(probe_kind)
break
except AdmissionDenied as denied:
if denied.slug != "queue_busy" or self._monotonic() + BUSY_RETRY_SECONDS >= busy_until:
@@ -228,9 +232,12 @@ class AdmissionPump:
advance by exactly one.
"""
- def __init__(self, sock, gate, *, monotonic=time.monotonic):
+ def __init__(self, sock, gate, *, kind="http", monotonic=time.monotonic):
+ if kind not in {"http", "control"}:
+ raise ValueError("invalid_admission_kind")
self.sock = sock
self.gate = gate
+ self.kind = kind
self._monotonic = monotonic
self._buffer = bytearray()
self._inflight_until = None
@@ -275,7 +282,7 @@ def _handle(self, line: bytes):
self._inflight_until = None
self._inflight_n = None
return None
- if set(message) == {"op", "kind", "n"} and message["op"] == "admit" and message["kind"] == "http":
+ if set(message) == {"op", "kind", "n"} and message["op"] == "admit" and message["kind"] == self.kind:
if self.inflight or number != self._last_n + 1:
# One connection at a time and strictly sequential requests.
return self._fail("admission_protocol_violation")
@@ -384,8 +391,11 @@ class AdmissionRefused(Exception):
class ChildAdmission:
"""Scanner-side client of the supervisor socket. Holds no key and no state of record."""
- def __init__(self, sock, *, monotonic=time.monotonic):
+ def __init__(self, sock, *, kind="http", monotonic=time.monotonic):
+ if kind not in {"http", "control"}:
+ raise ValueError("invalid_admission_kind")
self.sock = sock
+ self.kind = kind
self._monotonic = monotonic
self._granted_at = None
self._valid_seconds = 0.0
@@ -431,7 +441,7 @@ def admit(self):
self._n += 1
try:
self.sock.settimeout(5.0)
- self.sock.sendall(json.dumps({"op": "admit", "kind": "http", "n": self._n}, separators=(",", ":")).encode("ascii") + b"\n")
+ self.sock.sendall(json.dumps({"op": "admit", "kind": self.kind, "n": self._n}, separators=(",", ":")).encode("ascii") + b"\n")
except OSError:
self._poison("admission_channel_failed")
try:
diff --git a/integrations/worker/control.py b/integrations/worker/control.py
new file mode 100644
index 0000000..fe2338f
--- /dev/null
+++ b/integrations/worker/control.py
@@ -0,0 +1,218 @@
+"""Exact-target control checks: one DNS TXT lookup or one HTTPS file request.
+
+These run inside the unprivileged scanner child, under the same admission guard as a
+scan: the supervisor asks WordPress for an admission (``kind=control``) before the one
+query, and a refusal ends the run. Limits are the disclosed ones: a fixed name or path,
+no redirects, at most 4096 bytes, a ten-second timeout, one query per admission.
+
+Everything read from the network is untrusted data: it is length-bounded, decoded
+without execution, and compared by WordPress against a stored hash. The worker never
+learns the expected token.
+"""
+
+from __future__ import annotations
+
+import random
+import socket
+import struct
+import time
+from urllib.request import Request
+
+from .admission import AdmissionRefused
+
+CHALLENGE_PATH = "/.well-known/ct-scan-challenge.txt"
+DNS_LABEL = "_ct-scan-challenge"
+MAX_BYTES = 4096
+TIMEOUT_SECONDS = 10.0
+MAX_TXT_RECORDS = 8
+USER_AGENT = "php2shell-check-control/1 (+https://complete.tech/services/website-security/)"
+
+
+class ControlError(Exception):
+ """A lookup or fetch that could not be completed (reported as outcome ``error``)."""
+
+
+def _encode_name(name: str) -> bytes:
+ labels = name.rstrip(".").split(".")
+ out = bytearray()
+ for label in labels:
+ raw = label.encode("ascii")
+ if not 1 <= len(raw) <= 63:
+ raise ControlError("dns_name_invalid")
+ out.append(len(raw))
+ out.extend(raw)
+ out.append(0)
+ if len(out) > 255:
+ raise ControlError("dns_name_invalid")
+ return bytes(out)
+
+
+def build_txt_query(name: str, txid: int) -> bytes:
+ """A single recursive TXT/IN question."""
+ return struct.pack("!HHHHHH", txid, 0x0100, 1, 0, 0, 0) + _encode_name(name) + struct.pack("!HH", 16, 1)
+
+
+def _read_name(data: bytes, offset: int) -> tuple[bytes, int]:
+ """Read a (possibly compressed) name; return (lowercased wire name, offset after it in place)."""
+ labels = []
+ end = None
+ hops = 0
+ while True:
+ if offset >= len(data):
+ raise ControlError("dns_malformed")
+ length = data[offset]
+ if length & 0xC0 == 0xC0:
+ if offset + 1 >= len(data):
+ raise ControlError("dns_malformed")
+ pointer = ((length & 0x3F) << 8) | data[offset + 1]
+ if end is None:
+ end = offset + 2
+ hops += 1
+ if hops > 16 or pointer >= len(data):
+ raise ControlError("dns_malformed")
+ offset = pointer
+ continue
+ if length & 0xC0:
+ raise ControlError("dns_malformed")
+ offset += 1
+ if length == 0:
+ return b".".join(labels).lower(), (end if end is not None else offset)
+ if offset + length > len(data) or len(labels) > 127:
+ raise ControlError("dns_malformed")
+ labels.append(data[offset : offset + length])
+ offset += length
+
+
+def parse_txt_response(data: bytes, txid: int, name: str) -> list[str]:
+ """Return the TXT values for ``name``. NXDOMAIN or no data is an empty list.
+
+ Truncated (TC) answers, mismatched ids or questions, other error codes and malformed
+ packets raise ControlError; there is no fall back to TCP or another resolver.
+ """
+ if not isinstance(data, (bytes, bytearray)) or len(data) < 12 or len(data) > MAX_BYTES:
+ raise ControlError("dns_malformed")
+ ident, flags, qd, an, _ns, _ar = struct.unpack("!HHHHHH", data[:12])
+ if ident != txid or not flags & 0x8000:
+ raise ControlError("dns_mismatch")
+ if flags & 0x0200:
+ raise ControlError("dns_truncated")
+ rcode = flags & 0x000F
+ if rcode not in (0, 3):
+ raise ControlError("dns_error")
+ if qd != 1:
+ raise ControlError("dns_mismatch")
+ offset = 12
+ qname, offset = _read_name(data, offset)
+ if offset + 4 > len(data):
+ raise ControlError("dns_malformed")
+ qtype, qclass = struct.unpack("!HH", data[offset : offset + 4])
+ offset += 4
+ if qname != name.rstrip(".").encode("ascii").lower() or (qtype, qclass) != (16, 1):
+ raise ControlError("dns_mismatch")
+ values: list[str] = []
+ for _ in range(min(an, 64)):
+ rname, offset = _read_name(data, offset)
+ if offset + 10 > len(data):
+ raise ControlError("dns_malformed")
+ rtype, rclass, _ttl, rdlen = struct.unpack("!HHIH", data[offset : offset + 10])
+ offset += 10
+ if offset + rdlen > len(data):
+ raise ControlError("dns_malformed")
+ rdata = data[offset : offset + rdlen]
+ offset += rdlen
+ if rtype != 16 or rclass != 1 or rname != qname:
+ continue
+ parts = bytearray()
+ position = 0
+ while position < len(rdata):
+ length = rdata[position]
+ if position + 1 + length > len(rdata):
+ raise ControlError("dns_malformed")
+ parts.extend(rdata[position + 1 : position + 1 + length])
+ position += 1 + length
+ if len(values) >= MAX_TXT_RECORDS:
+ raise ControlError("dns_too_many_records")
+ values.append(bytes(parts).decode("utf-8", errors="replace"))
+ if sum(len(v.encode("utf-8")) for v in values) > MAX_BYTES:
+ raise ControlError("dns_too_large")
+ return values
+
+
+def system_resolver(path: str = "/etc/resolv.conf") -> tuple[str, int]:
+ """The first configured nameserver. No search domains, no fallbacks."""
+ try:
+ with open(path, "r", encoding="ascii", errors="replace") as handle:
+ for line in handle:
+ parts = line.split()
+ if len(parts) >= 2 and parts[0] == "nameserver":
+ return parts[1].split("%", 1)[0], 53
+ except OSError as exc:
+ raise ControlError("dns_resolver_unavailable") from exc
+ raise ControlError("dns_resolver_unavailable")
+
+
+def lookup_txt(host: str, resolver: tuple[str, int], guard, *, timeout: float = TIMEOUT_SECONDS, txid: int | None = None) -> list[str]:
+ """One TXT lookup of ``_ct-scan-challenge.`` through the configured resolver, once admitted."""
+ name = f"{DNS_LABEL}.{host}"
+ query_id = random.SystemRandom().randrange(1, 0x10000) if txid is None else txid
+ packet = build_txt_query(name, query_id)
+ family = socket.AF_INET6 if ":" in resolver[0] else socket.AF_INET
+ guard.admit()
+ try:
+ guard.ensure_fresh()
+ sock = socket.socket(family, socket.SOCK_DGRAM)
+ try:
+ sock.sendto(packet, resolver)
+ deadline = time.monotonic() + timeout # one overall budget; stray packets cannot extend it
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise ControlError("dns_timeout")
+ sock.settimeout(remaining)
+ try:
+ data, source = sock.recvfrom(MAX_BYTES + 1)
+ except socket.timeout as exc:
+ raise ControlError("dns_timeout") from exc
+ except OSError as exc:
+ raise ControlError("dns_network_error") from exc
+ if source[0] == resolver[0] and source[1] == resolver[1]:
+ break # anything from another source is ignored, not trusted
+ finally:
+ sock.close()
+ finally:
+ guard.done()
+ return parse_txt_response(data, query_id, name)
+
+
+def fetch_challenge_file(opener, host: str, timeout: float = TIMEOUT_SECONDS) -> tuple[str, list[str]]:
+ """GET the fixed challenge path once. The opener enforces scope, public addresses and admission.
+
+ Returns (outcome, observed). Redirects are not followed: a 3xx is "not_found".
+ """
+ request = Request(
+ f"https://{host}{CHALLENGE_PATH}",
+ headers={"User-Agent": USER_AGENT, "Accept": "text/plain", "Accept-Encoding": "identity"},
+ method="GET",
+ )
+ try:
+ stream = opener.open(request, timeout=timeout)
+ except AdmissionRefused:
+ raise
+ except (OSError, ValueError, TimeoutError):
+ return "error", []
+ try:
+ with stream:
+ raw = stream.read(MAX_BYTES + 1)
+ status = stream.code
+ except (OSError, TimeoutError):
+ return "error", []
+ if status == 200:
+ if len(raw) > MAX_BYTES:
+ return "error", []
+ try:
+ return "observed", [raw.decode("utf-8")]
+ except UnicodeDecodeError:
+ return "error", []
+ if status in (301, 302, 303, 307, 308, 404, 410):
+ return "not_found", []
+ return "error", []
diff --git a/integrations/worker/protocol.py b/integrations/worker/protocol.py
index e0eb409..cc5c41d 100644
--- a/integrations/worker/protocol.py
+++ b/integrations/worker/protocol.py
@@ -22,8 +22,10 @@
API_BASE_PATH = "/wp-json/ct-scan/v1"
AUTH_ROUTE_PREFIX = "/ct-scan/v1"
ROUTES = frozenset(
- f"{AUTH_ROUTE_PREFIX}/{name}" for name in ("claim", "start", "admit", "result")
+ f"{AUTH_ROUTE_PREFIX}/{name}" for name in ("claim", "start", "admit", "result", "control/claim", "control/result")
)
+CONTROL_METHODS = frozenset({"dns_txt", "https_file"})
+CONTROL_OUTCOMES = frozenset({"observed", "not_found", "error"})
API_MAX_BODY = 64 * 1024
API_MAX_RESPONSE = 16 * 1024
MAX_JOB_ID = 2**63 - 1
@@ -55,6 +57,19 @@ class JobClaim:
lease_until: datetime
+@dataclass(frozen=True)
+class ControlTask:
+ """One customer-requested target-control check: a single TXT lookup or HTTPS request."""
+
+ job_id: int
+ target: str
+ scope_hash: str
+ method: str
+ expires_at: datetime
+ lease: str
+ lease_until: datetime
+
+
def auth_headers(secret: bytes, timestamp: int, nonce: str, route: str, raw_body: bytes) -> dict[str, str]:
if route not in ROUTES:
raise ValueError("invalid_route")
@@ -123,6 +138,25 @@ def validate_claim(value, *, now: datetime | None = None) -> JobClaim | None:
return JobClaim(job_id, target, scope_hash, terms_hash, expires_at, lease, lease_until)
+def validate_control_task(value, *, now: datetime | None = None) -> ControlTask | None:
+ if not isinstance(value, dict) or set(value) != {"task"}:
+ raise APIError("invalid_claim_schema")
+ data = value["task"]
+ if data is None:
+ return None
+ required = {"id", "target", "scope_hash", "method", "expires_at", "lease", "lease_until"}
+ if not isinstance(data, dict) or set(data) != required:
+ raise APIError("invalid_job_schema")
+ # The binding rules are the scan claim's: same target normalization, hash, lease and bounds.
+ claim = validate_claim(
+ {"job": {**{k: data[k] for k in ("id", "target", "scope_hash", "expires_at", "lease", "lease_until")}, "terms_hash": "0" * 64}},
+ now=now,
+ )
+ if data["method"] not in CONTROL_METHODS:
+ raise APIError("invalid_control_method")
+ return ControlTask(claim.job_id, claim.target, claim.scope_hash, data["method"], claim.expires_at, claim.lease, claim.lease_until)
+
+
class APIClient:
"""One-shot API calls to the fixed CompleteTech HTTPS origin."""
@@ -191,7 +225,7 @@ def admit(self, claim: JobClaim, kind: str) -> dict:
A short timeout keeps a slow store from stalling the supervisor; a timeout is a refusal.
"""
- if kind not in {"http", "probe"}:
+ if kind not in {"http", "probe", "control", "control_probe"}:
raise APIError("invalid_admission_kind")
return self.post(
"/ct-scan/v1/admit",
@@ -199,6 +233,25 @@ def admit(self, claim: JobClaim, kind: str) -> dict:
timeout=5.0,
)
+ def control_claim(self) -> ControlTask | None:
+ response = self.post("/ct-scan/v1/control/claim", {})
+ return validate_control_task(response)
+
+ def control_result(self, task: ControlTask, outcome: str, observed: list[str]) -> dict:
+ if outcome not in CONTROL_OUTCOMES or not isinstance(observed, list):
+ raise APIError("invalid_control_result")
+ return self.post(
+ "/ct-scan/v1/control/result",
+ {
+ "id": task.job_id,
+ "lease": task.lease,
+ "scope_hash": task.scope_hash,
+ "method": task.method,
+ "outcome": outcome,
+ "observed": observed,
+ },
+ )
+
def result(self, body: bytes) -> dict:
response = self.post_raw("/ct-scan/v1/result", body)
return response
diff --git a/integrations/worker/runner.py b/integrations/worker/runner.py
index 8e463fc..098b2e2 100644
--- a/integrations/worker/runner.py
+++ b/integrations/worker/runner.py
@@ -113,7 +113,7 @@ def __init__(self, *, repo_root=None, popen=subprocess.Popen, monotonic=time.mon
self._popen = popen
self._monotonic = monotonic
- def run(self, target: str, *, timeout: float = MAX_WALL_SECONDS, stop_event=None, gate=None) -> dict:
+ def run(self, target: str, *, timeout: float = MAX_WALL_SECONDS, stop_event=None, gate=None, task=None) -> dict:
# Fail closed by construction: without an admission gate there is no scan.
if gate is None:
raise ScannerFailure("admission_guard_required")
@@ -129,7 +129,10 @@ def run(self, target: str, *, timeout: float = MAX_WALL_SECONDS, stop_event=None
raise ScannerFailure("invalid_target") from exc
if normalized != target:
raise ScannerFailure("invalid_target")
- payload = json.dumps({"target": normalized}, separators=(",", ":")).encode("utf-8")
+ if task not in {None, "control_dns", "control_https"}:
+ raise ScannerFailure("invalid_scanner_task")
+ message = {"target": normalized} if task is None else {"target": normalized, "task": task}
+ payload = json.dumps(message, separators=(",", ":")).encode("utf-8")
if len(payload) > MAX_SCANNER_INPUT:
raise ScannerFailure("scanner_input_too_large")
env = {
@@ -167,7 +170,7 @@ def run(self, target: str, *, timeout: float = MAX_WALL_SECONDS, stop_event=None
child_sock.close()
raise ScannerFailure("scanner_start_failed") from exc
child_sock.close()
- pump = AdmissionPump(parent_sock, gate)
+ pump = AdmissionPump(parent_sock, gate, kind="http" if task is None else "control")
try:
buffers = {"stdout": bytearray(), "stderr": bytearray()}
@@ -267,6 +270,8 @@ def run(self, target: str, *, timeout: float = MAX_WALL_SECONDS, stop_event=None
if process.returncode != 0:
child_error = self._parse_child_error(bytes(buffers["stdout"]))
raise ScannerFailure(child_error or "scanner_process_failed")
+ if task is not None:
+ return self._parse_control_success(bytes(buffers["stdout"]), task)
return self._parse_success(bytes(buffers["stdout"]), target, scope)
finally:
# Let an in-flight liveness probe finish before the socket and the API client are reused.
@@ -304,6 +309,27 @@ def _parse_child_error(cls, output: bytes):
error = parsed.get("error")
return error if parsed.get("ok") is False and isinstance(error, str) and SAFE_ERROR_RE.fullmatch(error) else None
+ @classmethod
+ def _parse_control_success(cls, output: bytes, task: str):
+ parsed = cls._parse_json_line(output)
+ if set(parsed) != {"ok", "control"} or parsed["ok"] is not True or not isinstance(parsed["control"], dict):
+ raise ScannerFailure("scanner_invalid_output")
+ control = parsed["control"]
+ method = "dns_txt" if task == "control_dns" else "https_file"
+ observed = control.get("observed")
+ if (
+ set(control) != {"method", "outcome", "observed"}
+ or control["method"] != method
+ or control["outcome"] not in {"observed", "not_found", "error"}
+ or not isinstance(observed, list)
+ or len(observed) > 8
+ or any(not isinstance(item, str) for item in observed)
+ or sum(len(item.encode("utf-8")) for item in observed) > 4096
+ or (control["outcome"] != "observed" and observed)
+ ):
+ raise ScannerFailure("scanner_result_schema")
+ return control
+
@classmethod
def _parse_success(cls, output: bytes, target: str, scope: RequestOrigin):
parsed = cls._parse_json_line(output)
diff --git a/integrations/worker/scanner.py b/integrations/worker/scanner.py
index 0271c93..84e8b58 100644
--- a/integrations/worker/scanner.py
+++ b/integrations/worker/scanner.py
@@ -13,6 +13,7 @@
from wordpress_check import Checker
from .admission import AdmissionRefused, ChildAdmission
+from .control import ControlError, fetch_challenge_file, lookup_txt, system_resolver
from .network import PinnedOpener, RequestOrigin
@@ -54,6 +55,24 @@ def _admission_channel():
return channel
+def _control(task, scope, guard):
+ """One exact-target control check: a single TXT lookup or HTTPS request, once admitted."""
+ method = "dns_txt" if task == "control_dns" else "https_file"
+ try:
+ if task == "control_dns":
+ observed = lookup_txt(scope.hostname, system_resolver(), guard)
+ outcome = "observed" if observed else "not_found"
+ else:
+ outcome, observed = fetch_challenge_file(PinnedOpener(scope, guard=guard), scope.hostname)
+ except AdmissionRefused as exc:
+ reason = str(exc)
+ slug = reason if reason.startswith("admission_") else "admission_" + reason
+ return _write({"ok": False, "error": slug[:64]}, 2)
+ except ControlError:
+ outcome, observed = "error", []
+ return _write({"ok": True, "control": {"method": method, "outcome": outcome, "observed": observed}}, 0)
+
+
def _timeout(_signum, _frame):
raise TimeoutError("scanner_timeout")
@@ -71,8 +90,14 @@ def main() -> int:
if len(raw) > MAX_INPUT_BYTES:
return _write({"ok": False, "error": "scanner_input_too_large"}, 2)
data = json.loads(raw.decode("utf-8"))
- if not isinstance(data, dict) or set(data) != {"target"} or not isinstance(data["target"], str):
+ if (
+ not isinstance(data, dict)
+ or set(data) not in ({"target"}, {"target", "task"})
+ or not isinstance(data["target"], str)
+ or data.get("task", "scan") not in {"scan", "control_dns", "control_https"}
+ ):
return _write({"ok": False, "error": "invalid_scanner_input"}, 2)
+ task = data.get("task", "scan")
scope, target = RequestOrigin.from_target(data["target"])
if target != data["target"]:
return _write({"ok": False, "error": "invalid_scanner_input"}, 2)
@@ -80,7 +105,9 @@ def main() -> int:
if channel is None:
# Fail closed: a scanner that cannot ask for admission never sends a request.
return _write({"ok": False, "error": "admission_channel_unavailable"}, 2)
- guard = ChildAdmission(channel)
+ guard = ChildAdmission(channel, kind="control" if task != "scan" else "http")
+ if task != "scan":
+ return _control(task, scope, guard)
checker = Checker(timeout=10.0, delay=1.0, contact=CONTACT)
checker.opener = PinnedOpener(scope, guard=guard)
try:
diff --git a/integrations/worker/service.py b/integrations/worker/service.py
index ca9704f..351ca44 100644
--- a/integrations/worker/service.py
+++ b/integrations/worker/service.py
@@ -231,7 +231,7 @@ def process_once(self):
return f"claim_failed_{exc.slug}"
self.spool.write_heartbeat(now)
if claim is None:
- return "idle"
+ return self._process_control()
try:
scope, normalized = RequestOrigin.from_target(claim.target)
@@ -274,6 +274,41 @@ def process_once(self):
return self._flush_one(*callbacks[0], int(self.clock()))
return "callback_spooled"
+ def _process_control(self):
+ """Run at most one customer-requested target-control check (one query), if one is due."""
+ try:
+ task = self.api.control_claim()
+ except APIError as exc:
+ return f"control_claim_failed_{exc.slug}"
+ if task is None:
+ return "idle"
+ try:
+ RequestOrigin.from_target(task.target)
+ except ValueError:
+ return "control_rejected_target"
+ remaining = min(task.expires_at.timestamp(), task.lease_until.timestamp()) - self.clock()
+ timeout = min(30.0, remaining - CALLBACK_RESERVE_SECONDS)
+ if timeout <= 0:
+ return "control_stale"
+ gate = AdmissionGate(self.api, task, kind="control", stop_event=self.stop_event, deadline=self.clock() + timeout, clock=self.clock)
+ runner_task = "control_dns" if task.method == "dns_txt" else "control_https"
+ try:
+ control = self.runner.run(task.target, timeout=timeout, stop_event=self.stop_event, gate=gate, task=runner_task)
+ outcome, observed = control["outcome"], control["observed"]
+ except ScannerFailure as exc:
+ if exc.slug.startswith("admission_"):
+ # Refused or cancelled: nothing was checked. WordPress releases the lease; no result is posted.
+ return f"control_{exc.slug}"
+ outcome, observed = "error", []
+ except (TypeError, ValueError, KeyError):
+ outcome, observed = "error", []
+ try:
+ self.api.control_result(task, outcome, observed)
+ except APIError as exc:
+ # The attempt was already spent at admission; there is no retry and no rescan.
+ return f"control_result_failed_{exc.slug}"
+ return f"control_{outcome}"
+
def run_forever(self, poll_seconds: int):
while not self.stop_event.is_set():
try:
diff --git a/test_admission.py b/test_admission.py
index fd07ad6..05babd2 100644
--- a/test_admission.py
+++ b/test_admission.py
@@ -285,6 +285,12 @@ def test_probe_retries_a_busy_queue_lock_within_one_call_budget(self):
self.assertEqual(denied.exception.slug, "authorization_revoked_or_expired")
self.assertEqual(self.api.calls, ["probe"])
+ def test_a_control_probe_retries_a_busy_queue_lock_on_its_own_route(self):
+ self.api = _AdmitAPI(_refusal(503, "queue_busy"), {"ok": True, "live": True})
+ gate = AdmissionGate(self.api, _claim(expires_at=NOW + 7200), kind="control", stop_event=self.stop, clock=lambda: NOW, monotonic=self.clock)
+ gate.probe()
+ self.assertEqual(self.api.calls, ["control_probe", "control_probe"])
+
class ApiClientAdmitTests(unittest.TestCase):
def test_admit_is_signed_for_its_own_route_with_a_short_timeout_and_exact_body(self):
diff --git a/test_control.py b/test_control.py
new file mode 100644
index 0000000..2ca3a2d
--- /dev/null
+++ b/test_control.py
@@ -0,0 +1,709 @@
+"""Offline tests for exact-target control checks (DNS TXT and HTTPS file).
+
+Only local sockets and servers are used; no external host or resolver is contacted.
+"""
+
+from __future__ import annotations
+
+import http.server
+import json
+import os
+import socket
+import struct
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from datetime import datetime, timezone
+from pathlib import Path
+from unittest import mock
+
+from integrations.worker import admission, control, network
+from integrations.worker.admission import AdmissionDenied, AdmissionGate, AdmissionRefused, Grant
+from integrations.worker.control import ControlError, build_txt_query, fetch_challenge_file, lookup_txt, parse_txt_response
+from integrations.worker.network import PinnedOpener
+from integrations.worker.protocol import APIClient, APIError, ControlTask, validate_control_task
+from integrations.worker.runner import ScannerFailure, SubprocessRunner
+from integrations.worker.service import WorkerService
+from integrations.worker.spool import DurableSpool
+
+from test_scan_worker import REVISION, TARGET, _FakeAPI
+
+NOW = 1_800_000_000
+HOST = "example.test"
+NAME = "_ct-scan-challenge.example.test"
+TOKEN = "ab" * 32
+
+
+def _name(name: str) -> bytes:
+ return b"".join(bytes([len(label)]) + label.encode() for label in name.split(".")) + b"\x00"
+
+
+def txt_rdata(*strings: bytes) -> bytes:
+ return b"".join(bytes([len(item)]) + item for item in strings)
+
+
+def dns_response(txid, answers=(), *, flags=0x8180, name=NAME, qtype=16, extra_header=None, compress=True):
+ """A TXT answer packet. ``answers`` are (rtype, rclass, rdata) triples."""
+ header = struct.pack("!HHHHHH", txid, flags, 1, len(answers), 0, 0)
+ question = _name(name) + struct.pack("!HH", qtype, 1)
+ body = b""
+ for rtype, rclass, rdata in answers:
+ owner = b"\xc0\x0c" if compress else _name(name)
+ body += owner + struct.pack("!HHIH", rtype, rclass, 60, len(rdata)) + rdata
+ return header + question + body
+
+
+class ParseTests(unittest.TestCase):
+ def test_query_is_one_recursive_txt_question(self):
+ packet = build_txt_query(NAME, 0x1234)
+ self.assertEqual(packet[:12], struct.pack("!HHHHHH", 0x1234, 0x0100, 1, 0, 0, 0))
+ self.assertEqual(packet[12:], _name(NAME) + struct.pack("!HH", 16, 1))
+ for bad in ("a" * 64 + ".example.test", ".".join(["a" * 60] * 5), "café.example.test"):
+ with self.subTest(bad=bad[:20]), self.assertRaises((ControlError, UnicodeEncodeError)):
+ build_txt_query(bad, 1)
+
+ def test_values_multiple_strings_are_concatenated_and_other_records_ignored(self):
+ packet = dns_response(7, [
+ (16, 1, txt_rdata(b"ct-scan-verify=", TOKEN.encode())),
+ (1, 1, b"\x01\x02\x03\x04"),
+ (16, 1, txt_rdata(b"v=spf1 -all")),
+ (16, 3, txt_rdata(b"wrong class")),
+ ])
+ self.assertEqual(parse_txt_response(packet, 7, NAME), ["ct-scan-verify=" + TOKEN, "v=spf1 -all"])
+ self.assertEqual(parse_txt_response(dns_response(7, [(16, 1, txt_rdata(b"x"))], compress=False), 7, NAME), ["x"])
+ self.assertEqual(parse_txt_response(dns_response(7, [], flags=0x8183), 7, NAME), [], "NXDOMAIN is simply not found")
+ self.assertEqual(parse_txt_response(dns_response(7, []), 7, NAME), [])
+
+ def test_untrustworthy_answers_are_errors_not_values(self):
+ good = dns_response(9, [(16, 1, txt_rdata(b"x"))])
+ cases = {
+ "wrong id": parse_args(dns_response(10, [(16, 1, txt_rdata(b"x"))]), 9),
+ "not a response": parse_args(dns_response(9, [], flags=0x0100), 9),
+ "truncated flag": parse_args(dns_response(9, [], flags=0x8380), 9),
+ "servfail": parse_args(dns_response(9, [], flags=0x8182), 9),
+ "refused": parse_args(dns_response(9, [], flags=0x8185), 9),
+ "other question": parse_args(dns_response(9, [(16, 1, txt_rdata(b"x"))], name="_ct-scan-challenge.other.test"), 9),
+ "wrong type": parse_args(dns_response(9, [], qtype=1), 9),
+ "short": parse_args(b"\x00" * 5, 9),
+ "empty": parse_args(b"", 9),
+ "cut mid-record": parse_args(good[:-3], 9),
+ "oversize": parse_args(good + b"\x00" * 5000, 9),
+ "bad string length": parse_args(dns_response(9, [(16, 1, b"\x09ab")]), 9),
+ "compression loop": parse_args(dns_response(9, [], name=NAME)[:12] + b"\xc0\x0c" + struct.pack("!HH", 16, 1), 9),
+ "pointer past end": parse_args(dns_response(9, [], name=NAME)[:12] + b"\xc0\xff" + struct.pack("!HH", 16, 1), 9),
+ "reserved label bits": parse_args(dns_response(9, [], name=NAME)[:12] + b"\x80" + b"\x00" * 6, 9),
+ "non-bytes": parse_args("text", 9),
+ }
+ for label, args in cases.items():
+ with self.subTest(label), self.assertRaises(ControlError):
+ parse_txt_response(*args)
+
+ def test_record_count_and_size_are_bounded(self):
+ many = dns_response(3, [(16, 1, txt_rdata(b"v%d" % i)) for i in range(9)])
+ with self.assertRaises(ControlError):
+ parse_txt_response(many, 3, NAME)
+ big = dns_response(3, [(16, 1, txt_rdata(b"a" * 255, b"b" * 255)) for _ in range(7)])
+ self.assertLessEqual(len(big), control.MAX_BYTES)
+ self.assertEqual(len(parse_txt_response(big, 3, NAME)), 7)
+ wide = dns_response(3, [(16, 1, txt_rdata(*[b"c" * 255] * 4)) for _ in range(4)] + [(16, 1, txt_rdata(*[b"d" * 255] * 4))])
+ with self.assertRaises(ControlError): # 5 x 1020 bytes of text
+ parse_txt_response(wide, 3, NAME)
+
+ def test_resolver_comes_from_the_first_nameserver_line_only(self):
+ with tempfile.TemporaryDirectory() as directory:
+ path = Path(directory) / "resolv.conf"
+ path.write_text("# c\nsearch evil.test\nnameserver 192.0.2.53\nnameserver 192.0.2.54\n", encoding="ascii")
+ self.assertEqual(control.system_resolver(str(path)), ("192.0.2.53", 53))
+ path.write_text("search only\n", encoding="ascii")
+ with self.assertRaises(ControlError):
+ control.system_resolver(str(path))
+ with self.assertRaises(ControlError):
+ control.system_resolver(str(Path(directory) / "missing"))
+
+
+def parse_args(packet, txid):
+ return (packet, txid, NAME)
+
+
+class _Guard:
+ def __init__(self, allow=1, stale=False):
+ self.allow, self.stale, self.events = allow, stale, []
+
+ def admit(self):
+ self.events.append("admit")
+ if self.allow <= 0:
+ raise AdmissionRefused("job_cancelled")
+ self.allow -= 1
+
+ def ensure_fresh(self):
+ self.events.append("fresh")
+ if self.stale:
+ raise AdmissionRefused("admission_expired")
+
+ def done(self):
+ self.events.append("done")
+
+
+class FakeDNS:
+ """Loopback UDP DNS server. ``respond`` maps the request packet to a list of reply packets."""
+
+ def __init__(self, test, respond):
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ self.sock.bind(("127.0.0.1", 0))
+ self.sock.settimeout(0.2)
+ self.port = self.sock.getsockname()[1]
+ self.requests = []
+ self.respond = respond
+ self.stop = threading.Event()
+ thread = threading.Thread(target=self.loop, daemon=True)
+ thread.start()
+ test.addCleanup(thread.join, 2)
+ test.addCleanup(self.sock.close)
+ test.addCleanup(self.stop.set)
+
+ def loop(self):
+ while not self.stop.is_set():
+ try:
+ data, addr = self.sock.recvfrom(4096)
+ except (socket.timeout, OSError):
+ continue
+ self.requests.append(data)
+ for reply in self.respond(data, addr) or []:
+ self.sock.sendto(reply, addr)
+
+
+def echo_txid(data):
+ return struct.unpack("!H", data[:2])[0]
+
+
+class LookupTests(unittest.TestCase):
+ def test_one_admitted_query_returns_the_published_value(self):
+ server = FakeDNS(self, lambda d, a: [dns_response(echo_txid(d), [(16, 1, txt_rdata(b"ct-scan-verify=" + TOKEN.encode()))])])
+ guard = _Guard()
+ values = lookup_txt(HOST, ("127.0.0.1", server.port), guard, timeout=2)
+ self.assertEqual(values, ["ct-scan-verify=" + TOKEN])
+ self.assertEqual(guard.events, ["admit", "fresh", "done"])
+ self.assertEqual(len(server.requests), 1)
+ self.assertEqual(server.requests[0][12:], _name(NAME) + struct.pack("!HH", 16, 1))
+
+ def test_refused_or_stale_admission_sends_no_query(self):
+ server = FakeDNS(self, lambda d, a: [])
+ with self.assertRaises(AdmissionRefused):
+ lookup_txt(HOST, ("127.0.0.1", server.port), _Guard(allow=0), timeout=1)
+ stale = _Guard(stale=True)
+ with self.assertRaises(AdmissionRefused):
+ lookup_txt(HOST, ("127.0.0.1", server.port), stale, timeout=1)
+ time.sleep(0.3)
+ self.assertEqual(server.requests, [])
+ self.assertEqual(stale.events, ["admit", "fresh", "done"])
+
+ def test_forged_replies_from_elsewhere_or_with_another_id_are_not_trusted(self):
+ forger = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ self.addCleanup(forger.close)
+ forger.bind(("127.0.0.1", 0))
+
+ def respond(data, client):
+ txid = echo_txid(data)
+ # A packet from a different source port arrives first; then a wrong id; then the real answer.
+ forger.sendto(dns_response(txid, [(16, 1, txt_rdata(b"forged"))]), client)
+ return [dns_response(txid ^ 1, [(16, 1, txt_rdata(b"wrong-id"))]), dns_response(txid, [(16, 1, txt_rdata(b"real"))])]
+
+ server = FakeDNS(self, respond)
+ guard = _Guard()
+ with self.assertRaises(ControlError) as wrong:
+ lookup_txt(HOST, ("127.0.0.1", server.port), guard, timeout=2)
+ self.assertEqual(str(wrong.exception), "dns_mismatch", "a wrong-id reply from the resolver must fail, not be skipped")
+ self.assertEqual(guard.events, ["admit", "fresh", "done"])
+
+ def test_silence_times_out_once_and_never_retries(self):
+ server = FakeDNS(self, lambda d, a: [])
+ guard = _Guard()
+ with self.assertRaises(ControlError) as timeout:
+ lookup_txt(HOST, ("127.0.0.1", server.port), guard, timeout=0.3)
+ self.assertEqual(str(timeout.exception), "dns_timeout")
+ time.sleep(0.3)
+ self.assertEqual(len(server.requests), 1)
+ self.assertEqual(guard.events[-1], "done")
+
+ def test_truncated_answer_is_an_error_without_a_tcp_fallback(self):
+ server = FakeDNS(self, lambda d, a: [dns_response(echo_txid(d), [], flags=0x8380)])
+ with self.assertRaises(ControlError) as truncated:
+ lookup_txt(HOST, ("127.0.0.1", server.port), _Guard(), timeout=2)
+ self.assertEqual(str(truncated.exception), "dns_truncated")
+
+
+class _LocalScope:
+ def __init__(self, port):
+ self.port = port
+
+ def validate_request(self, url):
+ from urllib.parse import urlsplit, urlunsplit
+
+ parts = urlsplit(url)
+ if parts.scheme != "https" or parts.hostname != HOST:
+ raise ValueError("request_out_of_scope")
+ return "http", self.port, HOST, urlunsplit(("", "", parts.path or "/", parts.query, ""))
+
+
+class _PlainAsHTTPS(network.PinnedHTTPConnection):
+ """Test double: speak plain HTTP to the loopback fixture where production uses TLS."""
+
+ def __init__(self, host, port, *, context=None, **kwargs):
+ super().__init__(host, port, **kwargs)
+
+
+class FileTests(unittest.TestCase):
+ def serve(self, status=200, body=b"", headers=None, delay=0.0):
+ seen = []
+
+ class Handler(http.server.BaseHTTPRequestHandler):
+ def do_GET(self):
+ seen.append((self.path, dict(self.headers)))
+ if delay:
+ time.sleep(delay)
+ try:
+ self.send_response(status)
+ for key, value in (headers or {}).items():
+ self.send_header(key, value)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ except OSError:
+ pass
+
+ def log_message(self, *_args):
+ return
+
+ server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ self.addCleanup(thread.join, 2)
+ self.addCleanup(server.server_close)
+ self.addCleanup(server.shutdown)
+ return server.server_port, seen
+
+ def fetch(self, port, guard, timeout=2):
+ opener = PinnedOpener(_LocalScope(port), guard=guard)
+ with mock.patch.object(network, "_public_addresses", return_value=[(socket.AF_INET, "127.0.0.1")]), mock.patch.object(network, "PinnedHTTPSConnection", _PlainAsHTTPS):
+ return fetch_challenge_file(opener, HOST, timeout)
+
+ def test_the_fixed_path_is_requested_once_with_a_descriptive_agent(self):
+ port, seen = self.serve(body=TOKEN.encode() + b"\n")
+ guard = _Guard()
+ self.assertEqual(self.fetch(port, guard), ("observed", [TOKEN + "\n"]))
+ self.assertEqual([path for path, _h in seen], ["/.well-known/ct-scan-challenge.txt"])
+ self.assertIn("php2shell-check-control", {k.lower(): v for k, v in seen[0][1].items()}["user-agent"])
+ self.assertEqual(guard.events, ["admit", "fresh", "done"])
+
+ def test_redirects_are_not_followed_and_are_not_found(self):
+ for status in (301, 302, 307, 308):
+ with self.subTest(status=status):
+ port, seen = self.serve(status=status, headers={"Location": "https://attacker.example.test/ct-scan-challenge.txt"})
+ self.assertEqual(self.fetch(port, _Guard()), ("not_found", []))
+ self.assertEqual(len(seen), 1)
+
+ def test_status_mapping(self):
+ for status, expected in ((404, "not_found"), (410, "not_found"), (500, "error"), (403, "error"), (429, "error"), (204, "error")):
+ with self.subTest(status=status):
+ port, _seen = self.serve(status=status, body=b"")
+ self.assertEqual(self.fetch(port, _Guard())[0], expected)
+
+ def test_size_limit_and_encoding(self):
+ port, _ = self.serve(body=b"a" * 4096)
+ self.assertEqual(self.fetch(port, _Guard())[0], "observed")
+ port, _ = self.serve(body=b"a" * 4097)
+ self.assertEqual(self.fetch(port, _Guard()), ("error", []), "an oversized body is never partially accepted")
+ port, _ = self.serve(body=b"\xff\xfe" + TOKEN.encode())
+ self.assertEqual(self.fetch(port, _Guard()), ("error", []))
+
+ def test_slow_server_times_out_without_a_second_request(self):
+ port, seen = self.serve(body=b"x", delay=1.5)
+ started = time.monotonic()
+ self.assertEqual(self.fetch(port, _Guard(), timeout=0.3), ("error", []))
+ self.assertLess(time.monotonic() - started, 1.4)
+ self.assertEqual(len(seen), 1)
+
+ def test_refused_admission_reaches_nothing(self):
+ port, seen = self.serve(body=TOKEN.encode())
+ with self.assertRaises(AdmissionRefused):
+ self.fetch(port, _Guard(allow=0))
+ self.assertEqual(seen, [])
+
+ def test_a_private_destination_is_refused_before_any_connection(self):
+ scope = network.RequestOrigin.from_target("https://example.test/")[0]
+ guard = _Guard()
+ opener = PinnedOpener(scope, guard=guard)
+ with mock.patch.object(network, "_public_addresses", side_effect=ValueError("non_public_address")):
+ self.assertEqual(fetch_challenge_file(opener, "example.test"), ("error", []))
+ self.assertEqual(guard.events, [], "no admission is spent on an unsafe destination")
+ # And another host can never be reached through the same opener.
+ self.assertEqual(fetch_challenge_file(opener, "attacker.example.test"), ("error", []))
+
+
+class ProtocolTests(unittest.TestCase):
+ def payload(self, **overrides):
+ data = {"id": 5, "target": TARGET, "scope_hash": __import__("hashlib").sha256(TARGET.encode()).hexdigest(), "method": "dns_txt", "expires_at": NOW + 7200, "lease": "l" * 64, "lease_until": NOW + 100}
+ data.update(overrides)
+ return {"task": data}
+
+ def test_task_validation_reuses_the_claim_rules(self):
+ now = datetime.fromtimestamp(NOW, timezone.utc)
+ task = validate_control_task(self.payload(), now=now)
+ self.assertIsInstance(task, ControlTask)
+ self.assertEqual((task.job_id, task.method, task.target), (5, "dns_txt", TARGET))
+ self.assertIsNone(validate_control_task({"task": None}, now=now))
+ bad = [
+ {}, {"task": 5}, {"task": {}}, self.payload(method="http"), self.payload(method=None), self.payload(target="https://other.example.test/"),
+ self.payload(scope_hash="0" * 64), self.payload(lease="short"), self.payload(expires_at=NOW - 1), self.payload(lease_until=NOW + 99999),
+ self.payload(id=True), self.payload(id=0), {"task": dict(self.payload()["task"], extra=1)}, {"task": None, "x": 1},
+ ]
+ for value in bad:
+ with self.subTest(value=str(value)[:60]), self.assertRaises(APIError):
+ validate_control_task(value, now=now)
+
+ def test_requests_are_signed_for_their_own_routes(self):
+ captured = []
+
+ class Transport:
+ def open(self, request, timeout):
+ from test_scan_worker import _Response
+
+ captured.append((request.full_url, json.loads(request.data), timeout))
+ return _Response(json.dumps({"task": None} if request.full_url.endswith("claim") else {"ok": True, "verified": False}).encode())
+
+ client = APIClient(b"s" * 32, transport=Transport(), clock=lambda: NOW)
+ self.assertIsNone(client.control_claim())
+ task = ControlTask(5, TARGET, "h" * 64, "https_file", datetime.fromtimestamp(NOW + 100, timezone.utc), "l" * 64, datetime.fromtimestamp(NOW + 50, timezone.utc))
+ self.assertEqual(client.control_result(task, "not_found", []), {"ok": True, "verified": False})
+ self.assertEqual(captured[0][0], "https://complete.tech/wp-json/ct-scan/v1/control/claim")
+ self.assertEqual(captured[1][0], "https://complete.tech/wp-json/ct-scan/v1/control/result")
+ self.assertEqual(captured[1][1], {"id": 5, "lease": "l" * 64, "scope_hash": "h" * 64, "method": "https_file", "outcome": "not_found", "observed": []})
+ for outcome, observed in (("verified", []), ("observed", "text"), (None, [])):
+ with self.subTest(outcome=outcome), self.assertRaises(APIError):
+ client.control_result(task, outcome, observed)
+ for kind in ("control", "control_probe"):
+ client.admit(task, kind)
+ self.assertEqual([c[1]["kind"] for c in captured[-2:]], ["control", "control_probe"])
+
+
+class GateKindTests(unittest.TestCase):
+ def test_control_gate_asks_for_control_admissions_and_probes(self):
+ calls = []
+
+ class API:
+ def admit(self, claim, kind):
+ calls.append(kind)
+ if kind == "control_probe":
+ return {"ok": True, "live": True}
+ return {"ok": True, "admitted": True, "sequence": 1, "valid_ms": 1000, "timeout_ms": 10000, "http_remaining": 2}
+
+ task = ControlTask(5, TARGET, "h" * 64, "dns_txt", datetime.fromtimestamp(NOW + 100, timezone.utc), "l" * 64, datetime.fromtimestamp(NOW + 50, timezone.utc))
+ gate = AdmissionGate(API(), task, kind="control", clock=lambda: NOW)
+ self.assertEqual(gate.admit().remaining, 2)
+ gate.probe()
+ self.assertEqual(calls, ["control", "control_probe"])
+ with self.assertRaises(ValueError):
+ AdmissionGate(API(), task, kind="tls")
+ # A scanner may not ask for another kind than its run was started for.
+ parent, child = socket.socketpair()
+ self.addCleanup(parent.close)
+ self.addCleanup(child.close)
+ pump = admission.AdmissionPump(parent, gate, kind="control")
+ child.sendall(b'{"op":"admit","kind":"http","n":1}\n')
+ time.sleep(0.05)
+ self.assertEqual(pump.service(), "admission_protocol_violation")
+
+ def test_an_already_admitted_lease_ends_the_check_without_retry(self):
+ calls = []
+
+ class API:
+ def admit(self, claim, kind):
+ calls.append(kind)
+ if len(calls) == 1:
+ raise APIError("api_http_409", status=409, payload={"ok": False, "reason": "already_admitted"})
+ return {"ok": True, "admitted": True, "sequence": 2, "valid_ms": 1000, "timeout_ms": 10000, "http_remaining": 1}
+
+ task = ControlTask(5, TARGET, "h" * 64, "dns_txt", datetime.fromtimestamp(NOW + 100, timezone.utc), "l" * 64, datetime.fromtimestamp(NOW + 50, timezone.utc))
+ gate = AdmissionGate(API(), task, kind="control", clock=lambda: NOW)
+ with self.assertRaises(AdmissionDenied) as denied:
+ gate.admit()
+ self.assertEqual(denied.exception.slug, "already_admitted")
+ self.assertEqual(calls, ["control"], "an already admitted lease must not be retried into a second check")
+
+
+@unittest.skipUnless(sys.platform.startswith("linux"), "the supervisor uses Linux process isolation APIs")
+class ScannerControlTests(unittest.TestCase):
+ """The real scanner entry point, doing a DNS control check over a socket pair and a fake resolver."""
+
+ def run_main(self, task, resolver_port=None, target="https://example.test/", grant=True):
+ import io
+
+ from integrations.worker import scanner
+
+ parent, child = socket.socketpair()
+ self.addCleanup(parent.close)
+ fd = os.dup(child.fileno())
+ child.close()
+ seen = []
+
+ def supervisor():
+ while True:
+ try:
+ data = parent.recv(256)
+ except OSError:
+ return
+ if not data:
+ return
+ for line in data.split(b"\n"):
+ if not line:
+ continue
+ message = json.loads(line.decode())
+ seen.append(message)
+ if message["op"] == "admit":
+ reply = {"ok": True, "n": message["n"], "valid_ms": 900} if grant else {"ok": False, "n": message["n"], "reason": "job_cancelled"}
+ parent.sendall(json.dumps(reply).encode() + b"\n")
+
+ thread = threading.Thread(target=supervisor, daemon=True)
+ thread.start()
+ stdout = io.BytesIO()
+
+ class Out:
+ buffer = stdout
+
+ class In:
+ buffer = io.BytesIO(json.dumps({"target": target, "task": task}).encode())
+
+ patches = [
+ mock.patch.object(scanner.os, "geteuid", return_value=65534),
+ mock.patch.object(scanner.sys, "stdin", In),
+ mock.patch.object(scanner.sys, "stdout", Out),
+ mock.patch.dict(os.environ, {"CT_ADMISSION_FD": str(fd)}),
+ ]
+ if resolver_port is not None:
+ patches.append(mock.patch.object(scanner, "system_resolver", return_value=("127.0.0.1", resolver_port)))
+ for patch in patches:
+ patch.start()
+ self.addCleanup(patch.stop)
+ code = scanner.main()
+ thread.join(0.3)
+ return code, json.loads(stdout.getvalue().decode()), seen
+
+ def test_dns_check_reports_what_was_published_through_an_admitted_query(self):
+ server = FakeDNS(self, lambda d, a: [dns_response(echo_txid(d), [(16, 1, txt_rdata(b"ct-scan-verify=" + TOKEN.encode()))])])
+ code, output, seen = self.run_main("control_dns", server.port)
+ self.assertEqual(code, 0)
+ self.assertEqual(output, {"ok": True, "control": {"method": "dns_txt", "outcome": "observed", "observed": ["ct-scan-verify=" + TOKEN]}})
+ self.assertEqual([m["op"] for m in seen], ["admit", "done"])
+ self.assertEqual(seen[0]["kind"], "control")
+ self.assertEqual(len(server.requests), 1)
+
+ def test_not_found_and_resolver_errors_are_outcomes(self):
+ server = FakeDNS(self, lambda d, a: [dns_response(echo_txid(d), [], flags=0x8183)])
+ self.assertEqual(self.run_main("control_dns", server.port)[1]["control"], {"method": "dns_txt", "outcome": "not_found", "observed": []})
+ server = FakeDNS(self, lambda d, a: [dns_response(echo_txid(d), [], flags=0x8182)])
+ self.assertEqual(self.run_main("control_dns", server.port)[1]["control"]["outcome"], "error")
+
+ def test_a_refused_admission_ends_the_run_and_sends_no_query(self):
+ server = FakeDNS(self, lambda d, a: [])
+ code, output, _seen = self.run_main("control_dns", server.port, grant=False)
+ time.sleep(0.2)
+ self.assertEqual((code, output), (2, {"ok": False, "error": "admission_job_cancelled"}))
+ self.assertEqual(server.requests, [])
+
+ def test_unknown_tasks_and_extra_input_are_refused(self):
+ import io
+
+ from integrations.worker import scanner
+
+ for message in ({"target": "https://example.test/", "task": "scan_everything"}, {"target": "https://example.test/", "task": "control_dns", "x": 1}, {"task": "control_dns"}):
+ stdout = io.BytesIO()
+
+ class Out:
+ buffer = stdout
+
+ class In:
+ buffer = io.BytesIO(json.dumps(message).encode())
+
+ with mock.patch.object(scanner.os, "geteuid", return_value=65534), mock.patch.object(scanner.sys, "stdin", In), mock.patch.object(scanner.sys, "stdout", Out):
+ self.assertEqual(scanner.main(), 2)
+ self.assertEqual(json.loads(stdout.getvalue().decode()), {"ok": False, "error": "invalid_scanner_input"})
+
+
+_CONTROL_CHILD = r'''
+import json, os, socket, sys
+from urllib.parse import urlsplit
+from integrations.worker.admission import ChildAdmission, AdmissionRefused
+
+payload = json.loads(sys.stdin.buffer.read().decode("utf-8"))
+host = urlsplit(payload["target"]).hostname
+task = payload["task"]
+kind = "http" if host == "wrongkind.example.test" else "control"
+guard = ChildAdmission(socket.socket(fileno=int(os.environ["CT_ADMISSION_FD"])), kind=kind)
+try:
+ guard.admit()
+ guard.ensure_fresh()
+ guard.done()
+except AdmissionRefused:
+ sys.stdout.write(json.dumps({"ok": False, "error": "fixture_refused"}) + "\n")
+ raise SystemExit(2)
+method = "dns_txt" if task == "control_dns" else "https_file"
+control = {"method": method, "outcome": "observed", "observed": ["ct-scan-verify=x"]}
+if host == "badoutcome.example.test":
+ control["outcome"] = "verified"
+if host == "toomany.example.test":
+ control["observed"] = ["a"] * 9
+if host == "wrongmethod.example.test":
+ control["method"] = "https_file" if method == "dns_txt" else "dns_txt"
+if host == "notfoundvalues.example.test":
+ control["outcome"] = "not_found"
+sys.stdout.write(json.dumps({"ok": True, "control": control}) + "\n")
+'''
+
+
+@unittest.skipUnless(sys.platform.startswith("linux"), "the supervisor uses Linux process isolation APIs")
+class SupervisorControlTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ root = Path(self.temp.name)
+ worker = root / "integrations" / "worker"
+ worker.mkdir(parents=True)
+ (root / "integrations" / "__init__.py").write_text("", encoding="utf-8")
+ (worker / "__init__.py").write_text("", encoding="utf-8")
+ real = Path(__file__).resolve().parent / "integrations" / "worker"
+ for name in ("admission.py", "network.py", "protocol.py"):
+ (worker / name).symlink_to(real / name)
+ (worker / "scanner.py").write_text(_CONTROL_CHILD, encoding="utf-8")
+ self.runner = SubprocessRunner(repo_root=str(root))
+ for patch in (
+ mock.patch("integrations.worker.runner.os.geteuid", return_value=0),
+ mock.patch("integrations.worker.runner._has_kill_capability", return_value=True),
+ mock.patch("integrations.worker.runner._linux_preexec_limits", lambda: None),
+ ):
+ patch.start()
+ self.addCleanup(patch.stop)
+
+ def gate(self):
+ class G:
+ admits = 0
+
+ def admit(self_inner):
+ G.admits += 1
+ return Grant(1, 1.0, 10.0, 2)
+
+ def probe(self_inner):
+ return None
+
+ return G()
+
+ def test_a_control_run_is_admitted_once_and_returns_the_validated_outcome(self):
+ gate = self.gate()
+ control = self.runner.run("https://example.test/", timeout=10, gate=gate, task="control_dns")
+ self.assertEqual(control, {"method": "dns_txt", "outcome": "observed", "observed": ["ct-scan-verify=x"]})
+ self.assertEqual(type(gate).admits, 1)
+
+ def test_malformed_control_output_is_rejected(self):
+ for host in ("badoutcome", "toomany", "wrongmethod", "notfoundvalues"):
+ with self.subTest(host=host), self.assertRaises(ScannerFailure) as failure:
+ self.runner.run(f"https://{host}.example.test/", timeout=10, gate=self.gate(), task="control_https")
+ self.assertEqual(failure.exception.slug, "scanner_result_schema")
+
+ def test_a_scanner_cannot_borrow_another_admission_kind(self):
+ with self.assertRaises(ScannerFailure) as failure:
+ self.runner.run("https://wrongkind.example.test/", timeout=10, gate=self.gate(), task="control_dns")
+ self.assertEqual(failure.exception.slug, "admission_protocol_violation")
+
+ def test_unknown_tasks_never_start(self):
+ with self.assertRaises(ScannerFailure) as failure:
+ self.runner.run("https://example.test/", timeout=10, gate=self.gate(), task="shell")
+ self.assertEqual(failure.exception.slug, "invalid_scanner_task")
+
+
+class ServiceControlTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.spool = DurableSpool(Path(self.temp.name) / "spool")
+ self.spool.prepare()
+
+ def task(self, method="dns_txt"):
+ return ControlTask(5, TARGET, __import__("hashlib").sha256(TARGET.encode()).hexdigest(), method, datetime.fromtimestamp(NOW + 7200, timezone.utc), "l" * 64, datetime.fromtimestamp(NOW + 100, timezone.utc))
+
+ def service(self, task, runner_behavior):
+ class API(_FakeAPI):
+ def __init__(self_inner):
+ super().__init__()
+ self_inner.results = []
+ self_inner.claim_error = None
+
+ def control_claim(self_inner):
+ if self_inner.claim_error:
+ raise self_inner.claim_error
+ return task
+
+ def control_result(self_inner, claimed, outcome, observed):
+ self_inner.results.append((claimed.job_id, claimed.method, outcome, observed))
+ return {"ok": True}
+
+ class Runner:
+ calls = []
+
+ def run(self_inner, target, *, timeout, stop_event=None, gate=None, task=None):
+ Runner.calls.append((target, task, type(gate).__name__, gate.kind))
+ return runner_behavior(target)
+
+ api = API()
+ self.runner = Runner
+ return WorkerService(api, self.spool, Runner(), REVISION, clock=lambda: NOW), api
+
+ def test_idle_when_no_check_is_due(self):
+ service, api = self.service(None, lambda target: None)
+ self.assertEqual(service.process_once(), "idle")
+ self.assertEqual(api.results, [])
+
+ def test_a_due_check_runs_once_with_a_control_gate_and_reports_its_outcome(self):
+ service, api = self.service(self.task("https_file"), lambda target: {"method": "https_file", "outcome": "observed", "observed": [TOKEN]})
+ self.assertEqual(service.process_once(), "control_observed")
+ self.assertEqual(self.runner.calls, [(TARGET, "control_https", "AdmissionGate", "control")])
+ self.assertEqual(api.results, [(5, "https_file", "observed", [TOKEN])])
+ self.assertEqual(api.start_calls, [])
+
+ def test_scanner_errors_are_reported_as_error_but_admission_failures_post_nothing(self):
+ def boom(_target):
+ raise ScannerFailure("scanner_timeout")
+
+ service, api = self.service(self.task(), boom)
+ self.assertEqual(service.process_once(), "control_error")
+ self.assertEqual(api.results, [(5, "dns_txt", "error", [])])
+
+ def cancelled(_target):
+ raise ScannerFailure("admission_job_cancelled")
+
+ service, api = self.service(self.task(), cancelled)
+ self.assertEqual(service.process_once(), "control_admission_job_cancelled")
+ self.assertEqual(api.results, [])
+
+ def test_claim_failure_and_expired_task_do_nothing(self):
+ service, api = self.service(self.task(), lambda target: None)
+ api.claim_error = APIError("api_network_error")
+ self.assertEqual(service.process_once(), "control_claim_failed_api_network_error")
+ stale = ControlTask(5, TARGET, "h" * 64, "dns_txt", datetime.fromtimestamp(NOW + 1, timezone.utc), "l" * 64, datetime.fromtimestamp(NOW + 1, timezone.utc))
+ service, api = self.service(stale, lambda target: None)
+ self.assertEqual(service.process_once(), "control_stale")
+ self.assertEqual(self.runner.calls, [])
+
+ def test_a_failed_result_post_is_not_retried_into_another_check(self):
+ service, api = self.service(self.task(), lambda target: {"method": "dns_txt", "outcome": "not_found", "observed": []})
+
+ def failing(claimed, outcome, observed):
+ raise APIError("api_http_410", status=410)
+
+ api.control_result = failing
+ self.assertEqual(service.process_once(), "control_result_failed_api_http_410")
+ self.assertEqual(len(self.runner.calls), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test_scan_worker.py b/test_scan_worker.py
index 3be7164..e75f0ef 100644
--- a/test_scan_worker.py
+++ b/test_scan_worker.py
@@ -105,6 +105,9 @@ def start(self, claim):
raise self.start_error
return True
+ def control_claim(self):
+ return None
+
def result(self, body):
self.result_bodies.append(body)
if self.result_errors: