Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ jobs:
run: docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-bootstrap.php
- name: Test queue, confirmation and receipts
run: docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/integration.php
- name: Test per-request admission, cancellation race and fail-closed behavior
run: docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/integrations/wordpress/tests/admission.php
- name: Test Python to WordPress over HTTP with synthetic scan responses
run: |
fixture=$(docker compose -p php2shell-ci -f deployment/compose.test.yaml exec -T wordpress php /work/deployment/test-http-fixture.php setup)
Expand All @@ -60,7 +62,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
python -m unittest -v test_scan_worker test_admission
- name: Build and check packaged isolation
run: |
docker build --build-arg CHECKER_REVISION="$GITHUB_SHA" -f deployment/Dockerfile -t ct-worker-fixture .
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ shared development instructions; [CLAUDE.md](CLAUDE.md) points Claude to them.
- [Full reference](docs/REFERENCE.md): every CLI option, output field, and request rule
- [Assessment methodology](docs/PHP2SHELL.md): how each version is classified
- [WordPress free-scan integration](docs/WORDPRESS.md): signed requests, confirmation,
private job queue, restricted worker, deployment and recovery
private job queue, restricted worker, deployment and recovery. **Activation
requires enforced network egress** (`egress_enforced`); the repository's
Compose file does not provide it.
- [Contributing](CONTRIBUTING.md) · [Security policy](SECURITY.md)

```sh
Expand Down
47 changes: 47 additions & 0 deletions deployment/egress-filter.nft.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# EXAMPLE ONLY. NOT VERIFIED in this repository's tests and not applied by deployment/compose.yaml.
#
# The per-request admission guard is advisory to a scanner that ignores it. Activation of the
# integration REQUIRES that outbound traffic from the worker container is restricted by something the
# scanner cannot change: this host-level nftables sketch shows the intended policy. Adapt it, test it
# from inside a throwaway container against a local listener (never a third-party host), and only
# then set `egress_enforced => true` in the protected plugin configuration.
#
# Intent for the worker container's bridge ("BRIDGE", set with the network option
# com.docker.network.bridge.name):
# - allow DNS to ONE resolver you control ("RESOLVER"), nothing else on port 53/853;
# - allow TCP 80 and 443 to PUBLIC addresses only (the customer's site, and the fixed API origin);
# - drop everything else, including every private, link-local, CGNAT, documentation, multicast and
# cloud-metadata range, and all IPv6 that is not global unicast.
table inet ct_php2shell_egress {
set deny_v4 {
type ipv4_addr
flags interval
elements = {
0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12,
192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.18.0.0/15,
198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4
}
}
set deny_v6 {
type ipv6_addr
flags interval
elements = {
::/128, ::1/128, ::ffff:0:0/96, 64:ff9b::/96, 64:ff9b:1::/48, 100::/64, 2001::/23,
2001:db8::/32, 2002::/16, 3fff::/20, 5f00::/16, fc00::/7, fe80::/10, fec0::/10, ff00::/8
}
}
chain forward {
type filter hook forward priority -10; policy accept;
iifname "BRIDGE" jump worker_egress
}
chain worker_egress {
ct state established,related accept
ip daddr RESOLVER udp dport 53 accept
ip daddr RESOLVER tcp dport 53 accept
ip daddr @deny_v4 counter drop
ip6 daddr @deny_v6 counter drop
ip6 daddr != 2000::/3 counter drop
tcp dport { 80, 443 } accept
counter drop
}
}
1 change: 1 addition & 0 deletions deployment/test-http-fixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
file_put_contents($private . '/.htaccess', "Require all denied\n");
file_put_contents($private . '/config.php', '<?php return ' . var_export(array(
'enabled' => true,
'egress_enforced' => true, // disposable fixture: no network beyond the test network
'worker_hmac_secret' => str_repeat('fixture-', 8),
'expected_checker_revision' => str_repeat('a', 40),
'authorization_end_at' => gmdate('Y-m-d\TH:i:s\Z', time() + 86400 * 30),
Expand Down
51 changes: 45 additions & 6 deletions deployment/test-http-worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@
from email.message import Message
from urllib.parse import urlsplit, urlencode

from integrations.worker.admission import AdmissionDenied, AdmissionGate, parse_grant
from integrations.worker.protocol import APIClient, APIError
from integrations.worker.runner import SubprocessRunner
from integrations.worker.network import RequestOrigin
from wordpress_check import Checker


# The disposable Compose service is "wordpress:80"; a local run may override both.
FIXTURE_HOST = os.environ.get('CT_FIXTURE_HOST', 'wordpress')
FIXTURE_PORT = int(os.environ.get('CT_FIXTURE_PORT', '80'))


class Response:
def __init__(self, response, connection):
self.response = response
Expand All @@ -39,8 +45,8 @@ def __exit__(self, *_):
class FixtureTransport:
def open(self, request, timeout):
route = urlsplit(request.full_url).path.removeprefix('/wp-json')
assert route in {'/ct-scan/v1/claim', '/ct-scan/v1/start', '/ct-scan/v1/result'}
connection = http.client.HTTPConnection('wordpress', 80, timeout=timeout)
assert route in {'/ct-scan/v1/claim', '/ct-scan/v1/start', '/ct-scan/v1/admit', '/ct-scan/v1/result'}
connection = http.client.HTTPConnection(FIXTURE_HOST, FIXTURE_PORT, timeout=timeout)
connection.request('POST', '/?rest_route=' + route, body=request.data, headers=dict(request.header_items()))
response = connection.getresponse()
return Response(response, connection)
Expand Down Expand Up @@ -74,7 +80,7 @@ def open(self, request, timeout):
token = os.environ.get('CT_FIXTURE_TOKEN')
if token:
assert re.fullmatch('[a-f0-9]{64}', token)
connection = http.client.HTTPConnection('wordpress', timeout=10)
connection = http.client.HTTPConnection(FIXTURE_HOST, FIXTURE_PORT, timeout=10)
connection.request('POST', '/?rest_route=/ct-scan/v1/claim', '{}', {'Content-Type': 'application/json'})
response = connection.getresponse()
response.read()
Expand All @@ -89,7 +95,7 @@ def open(self, request, timeout):
assert error.status == 409
else:
raise AssertionError('Replayed signed request accepted')
connection = http.client.HTTPConnection('wordpress', timeout=10)
connection = http.client.HTTPConnection(FIXTURE_HOST, FIXTURE_PORT, timeout=10)
connection.request('GET', '/wp-admin/admin-post.php?action=ct_scan_confirm&token=' + token)
response = connection.getresponse()
page = response.read().decode()
Expand All @@ -100,7 +106,7 @@ def open(self, request, timeout):
fields = dict(re.findall(r'name="([^"]+)" value="([^"]*)"', page))
form = urlencode(fields)
for expected_status in (200, 410):
connection = http.client.HTTPConnection('wordpress', timeout=10)
connection = http.client.HTTPConnection(FIXTURE_HOST, FIXTURE_PORT, timeout=10)
connection.request('POST', '/wp-admin/admin-post.php', form,
{'Content-Type': 'application/x-www-form-urlencoded'})
response = connection.getresponse()
Expand All @@ -123,6 +129,26 @@ def concurrent_claim(_):
assert claim and claim.target == 'https://http-fixture.example.com/'
assert api.claim() is None, 'Concurrent worker obtained a second lease'
assert api.start(claim)

# Per-request admission over the real signed HTTP boundary.
assert api.admit(claim, 'probe') == {'ok': True, 'live': True}
first = parse_grant(api.admit(claim, 'http'))
assert (first.sequence, first.valid_seconds, first.timeout_seconds, first.remaining) == (1, 1.0, 10.0, 19)
try:
api.admit(claim, 'http')
except APIError as error:
assert error.status == 429 and error.payload['reason'] == 'rate_wait', error.payload
else:
raise AssertionError('A second admission inside the pacing interval was granted')
second = AdmissionGate(api, claim).admit() # waits out the interval, then is granted
assert second.sequence == 2 and second.remaining == 18
bad = dict(id=claim.job_id, lease='f' * 64, scope_hash=claim.scope_hash, kind='http')
try:
api.post('/ct-scan/v1/admit', bad)
except APIError as error:
assert error.status == 410 and error.payload['reason'] == 'lease_invalid_or_expired', error.payload
else:
raise AssertionError('Admission with a foreign lease was granted')
checker = Checker(delay=0)
checker.opener = SyntheticOpener()
result = dataclasses.asdict(checker.check(claim.target))
Expand All @@ -134,4 +160,17 @@ def concurrent_claim(_):
assert api.result(body) == {'ok': True}
assert api.result(body) == {'ok': True}, 'Identical receipt retry must be idempotent'
assert api.claim() is None
print('HTTP claim/start/result/receipt-retry passed; scanner used synthetic responses only.')
for kind in ('http', 'probe'):
try:
api.admit(claim, kind)
except APIError as error:
assert error.status == 410 and error.payload['reason'] == 'job_not_running', error.payload
else:
raise AssertionError('A finished job was admitted')
try:
AdmissionGate(api, claim).admit()
except AdmissionDenied as denied:
assert denied.slug == 'job_not_running'
else:
raise AssertionError('The gate admitted a finished job')
print('HTTP claim/start/admit/result/receipt-retry passed; scanner used synthetic responses only.')
17 changes: 16 additions & 1 deletion deployment/test-runtime-image.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import sys
import tempfile

from integrations.worker.admission import AdmissionDenied
from integrations.worker.runner import SubprocessRunner, _linux_preexec_limits
from integrations.worker.service import load_secret

Expand All @@ -30,7 +31,21 @@
env={'PATH': '/usr/local/bin:/usr/bin:/bin'},
preexec_fn=_linux_preexec_limits)

result = SubprocessRunner().run('https://runtime-fixture.example.invalid/', timeout=15)
class RefusingGate:
# With no network the lookup fails before any admission is requested; if the
# scanner ever asked, a refusal here would fail the run.
admits = 0

def admit(self):
RefusingGate.admits += 1
raise AdmissionDenied('job_cancelled')

def probe(self):
return None


result = SubprocessRunner().run('https://runtime-fixture.example.invalid/', timeout=15, gate=RefusingGate())
assert RefusingGate.admits == 0, 'an admission was requested although the destination never resolved'
assert result['status'] == 'error', result['status']
assert result['php2shell_status'] == 'unknown'
assert result['php2shell_exploitability'] == 'not_tested'
Expand Down
4 changes: 4 additions & 0 deletions deployment/wordpress-config.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
// The adjacent worker.key must contain the same random key as the worker volume.
// Never put either deployed file in source control.
return array(
// Keep false until EVERY item in docs/WORDPRESS.md "Egress" and the site's activation checklist is done.
'enabled' => false,
// Set true only after outbound traffic from the worker is restricted by something the scanner
// cannot change (see deployment/egress-filter.nft.example). While false the integration refuses to enable.
'egress_enforced' => false,
'supported_terms_version' => '2026-10-21-r2',
'expected_checker_revision' => 'REPLACE_WITH_REVIEWED_40_CHARACTER_COMMIT_SHA',
'expected_rules_as_of' => '2026-10-21',
Expand Down
Loading
Loading