Skip to content

Commit 051305a

Browse files
committed
feat: exercise AJAX and XML-RPC adversarial surfaces
1 parent db2ebb3 commit 051305a

4 files changed

Lines changed: 102 additions & 8 deletions

File tree

examples/recipes/wordpress-adversarial-vulnerable.json

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
"inputs": {
99
"mounts": [
1010
{
11-
"source": "../../tests/fixtures/wordpress-adversarial/vulnerable-plugin",
12-
"target": "/wordpress/wp-content/plugins/wp-codebox-adversarial-vulnerable",
11+
"source": "../../tests/fixtures/wordpress-adversarial/vulnerable-plugin/vulnerable-plugin.php",
12+
"target": "/wordpress/wp-content/mu-plugins/wp-codebox-adversarial-vulnerable.php",
13+
"type": "file",
1314
"mode": "readonly",
1415
"metadata": { "kind": "fixture", "disposableOnly": true }
1516
},
@@ -25,7 +26,7 @@
2526
"steps": [
2627
{
2728
"command": "wordpress.run-php",
28-
"args": ["code=require_once ABSPATH . 'wp-admin/includes/plugin.php'; activate_plugin('wp-codebox-adversarial-vulnerable/vulnerable-plugin.php'); switch_theme('wp-codebox-adversarial-vulnerable'); foreach (array('wp_codebox_vulnerable_authorization', 'wp_codebox_vulnerable_partial_state', 'wp_codebox_vulnerable_injection', 'wp_codebox_vulnerable_race_count', 'wp_codebox_adversarial_http_faults', 'wp_codebox_adversarial_http_fault_counts') as $option) { delete_option($option); }"]
29+
"args": ["code=switch_theme('wp-codebox-adversarial-vulnerable'); foreach (array('wp_codebox_vulnerable_authorization', 'wp_codebox_vulnerable_partial_state', 'wp_codebox_vulnerable_ajax', 'wp_codebox_vulnerable_xmlrpc', 'wp_codebox_vulnerable_injection', 'wp_codebox_vulnerable_race_count', 'wp_codebox_adversarial_http_faults', 'wp_codebox_adversarial_http_fault_counts') as $option) { delete_option($option); }"]
2930
}
3031
]
3132
},
@@ -87,6 +88,62 @@
8788
"replay": { "selection": "findings" },
8889
"metadata": { "fixtureDefect": "state-corruption", "network": "deny" }
8990
},
91+
{
92+
"schema": "wp-codebox/adversarial-recipe-campaign/v1",
93+
"id": "wordpress-vulnerable-ajax",
94+
"seed": "wordpress-vulnerable-ajax-v1",
95+
"corpus": [
96+
{ "id": "unauthenticated-ajax", "actions": [{ "type": "ajax-save", "input": { "value": "fixture-value" } }], "signals": ["transport:ajax"] }
97+
],
98+
"caseTemplates": [
99+
{
100+
"id": "ajax-save",
101+
"phases": {
102+
"action": [{ "command": "wordpress.browser-actions", "args": ["steps-json=[{\"kind\":\"navigate\",\"url\":\"/\",\"waitFor\":\"load\"},{\"kind\":\"evaluate\",\"expression\":\"const response = await fetch('/wp-admin/admin-ajax.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'action=wp_codebox_vulnerable_save&value=fixture-value' }); return response.ok\",\"assert\":true}]", "capture=steps,network", "timeout=30s"] }],
103+
"assert": [{ "command": "wordpress.run-php", "args": ["code=if (get_option('wp_codebox_vulnerable_ajax', null) !== null) { throw new RuntimeException('ajax-nonce-bypass'); }"] }],
104+
"teardown": [{ "command": "wordpress.run-php", "args": ["code=delete_option('wp_codebox_vulnerable_ajax');"] }]
105+
}
106+
}
107+
],
108+
"mutators": ["scalar"],
109+
"oracles": [{ "id": "runtime-status", "severity": "critical", "description": "Unauthenticated AJAX writes without a nonce must fail closed." }],
110+
"concurrency": 1,
111+
"budgets": { "maxCases": 1, "maxActionsPerCase": 2, "maxInputBytes": 4096, "maxCaseTimeMs": 15000, "maxWallTimeMs": 120000, "maxArtifactBytes": 10485760 },
112+
"resetPolicy": { "mode": "none" },
113+
"requiredCapabilities": ["adversarial-campaign", "artifact-export", "bounded-evidence", "replay", "command:wordpress.browser-actions", "command:wordpress.run-php"],
114+
"novelty": { "retainSignals": true },
115+
"shrinking": { "enabled": false },
116+
"replay": { "selection": "findings" },
117+
"metadata": { "fixtureDefect": "ajax-nonce-bypass", "transportFidelity": "exact", "network": "deny" }
118+
},
119+
{
120+
"schema": "wp-codebox/adversarial-recipe-campaign/v1",
121+
"id": "wordpress-vulnerable-xmlrpc",
122+
"seed": "wordpress-vulnerable-xmlrpc-v1",
123+
"corpus": [
124+
{ "id": "unauthenticated-xmlrpc", "actions": [{ "type": "xmlrpc-save", "input": {} }], "signals": ["transport:xmlrpc"] }
125+
],
126+
"caseTemplates": [
127+
{
128+
"id": "xmlrpc-save",
129+
"phases": {
130+
"action": [{ "command": "wordpress.browser-actions", "args": ["steps-json=[{\"kind\":\"navigate\",\"url\":\"/\",\"waitFor\":\"load\"},{\"kind\":\"evaluate\",\"expression\":\"const response = await fetch('/xmlrpc.php', { method: 'POST', headers: { 'Content-Type': 'text/xml' }, body: '<?xml version=\\\"1.0\\\"?><methodCall><methodName>wpCodebox.vulnerable</methodName><params><param><value><string>fixture-value</string></value></param></params></methodCall>' }); return response.ok\",\"assert\":true}]", "capture=steps,network", "timeout=30s"] }],
131+
"assert": [{ "command": "wordpress.run-php", "args": ["code=if (get_option('wp_codebox_vulnerable_xmlrpc', null) !== null) { throw new RuntimeException('xmlrpc-authorization-bypass'); }"] }],
132+
"teardown": [{ "command": "wordpress.run-php", "args": ["code=delete_option('wp_codebox_vulnerable_xmlrpc');"] }]
133+
}
134+
}
135+
],
136+
"mutators": ["scalar"],
137+
"oracles": [{ "id": "runtime-status", "severity": "critical", "description": "Unauthenticated XML-RPC writes must fail closed." }],
138+
"concurrency": 1,
139+
"budgets": { "maxCases": 1, "maxActionsPerCase": 2, "maxInputBytes": 4096, "maxCaseTimeMs": 15000, "maxWallTimeMs": 120000, "maxArtifactBytes": 10485760 },
140+
"resetPolicy": { "mode": "none" },
141+
"requiredCapabilities": ["adversarial-campaign", "artifact-export", "bounded-evidence", "replay", "command:wordpress.browser-actions", "command:wordpress.run-php"],
142+
"novelty": { "retainSignals": true },
143+
"shrinking": { "enabled": false },
144+
"replay": { "selection": "findings" },
145+
"metadata": { "fixtureDefect": "xmlrpc-authorization-bypass", "transportFidelity": "exact", "network": "deny" }
146+
},
90147
{
91148
"schema": "wp-codebox/adversarial-recipe-campaign/v1",
92149
"id": "wordpress-vulnerable-injection",

packages/runtime-playground/src/wordpress-adversarial-adapter.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ export interface WordPressAdversarialAdapter {
6161

6262
export const WORDPRESS_ADVERSARIAL_CAPABILITIES: readonly WordPressAdversarialCapability[] = [
6363
{ surface: "rest", fidelity: "exact", reason: "Dispatched through WP_REST_Request and rest_do_request()." },
64-
{ surface: "ajax", fidelity: "unsupported", reason: "In-process admin-ajax callbacks may terminate PHP; use browser/server requests when an HTTP runtime is available." },
65-
{ surface: "xmlrpc", fidelity: "unsupported", reason: "The disposable in-process runner does not provide a faithful XML-RPC HTTP request boundary." },
64+
{ surface: "ajax", fidelity: "exact", reason: "Dispatched through the runtime HTTP server to wp-admin/admin-ajax.php, preserving HTTP and wp_die() termination semantics." },
65+
{ surface: "xmlrpc", fidelity: "exact", reason: "Dispatched as text/xml through the runtime HTTP server to xmlrpc.php." },
6666
{ surface: "block", fidelity: "exact", reason: "Parsed, serialized, and rendered with WordPress block APIs." },
6767
{ surface: "shortcode", fidelity: "exact", reason: "Executed through do_shortcode()." },
6868
{ surface: "serialized-value", fidelity: "exact", reason: "Round-tripped through maybe_serialize() and maybe_unserialize()." },
@@ -137,6 +137,15 @@ export function wordpressAdversarialActionSpec(action: WordPressAdversarialActio
137137
if (!command) throw new Error("WordPress adversarial CLI actions require a non-empty string input.")
138138
return { kind: "command", command: "wordpress.wp-cli", args: [`command=${command}`], operation: `adversarial:${action.surface}`, metadata: actionMetadata(action, capability) }
139139
}
140+
if (action.surface === "ajax") {
141+
if (!action.target) throw new Error("WordPress adversarial AJAX actions require the target action name.")
142+
const body = new URLSearchParams({ action: action.target, ...httpFormValues(action.input) }).toString()
143+
return { kind: "command", command: "wordpress.browser-actions", args: [`steps-json=${JSON.stringify(httpBrowserSteps("/wp-admin/admin-ajax.php", "application/x-www-form-urlencoded", body))}`], operation: "adversarial:ajax", metadata: actionMetadata(action, capability) }
144+
}
145+
if (action.surface === "xmlrpc") {
146+
if (typeof action.input !== "string" || action.input.trim() === "") throw new Error("WordPress adversarial XML-RPC actions require a non-empty XML request body.")
147+
return { kind: "command", command: "wordpress.browser-actions", args: [`steps-json=${JSON.stringify(httpBrowserSteps("/xmlrpc.php", "text/xml", action.input))}`], operation: "adversarial:xmlrpc", metadata: actionMetadata(action, capability) }
148+
}
140149
return {
141150
kind: "command",
142151
command: "wordpress.run-php",
@@ -309,3 +318,13 @@ function metricBucket(value: number): string {
309318
function recordValue(value: unknown): Record<string, unknown> | undefined {
310319
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined
311320
}
321+
322+
function httpFormValues(value: unknown): Record<string, string> {
323+
const record = recordValue(value)
324+
return Object.fromEntries(Object.entries(record ?? {}).map(([name, item]) => [name, typeof item === "string" ? item : JSON.stringify(item)]))
325+
}
326+
327+
function httpBrowserSteps(path: string, contentType: string, body: string): Array<Record<string, unknown>> {
328+
const expression = `const response = await fetch(${JSON.stringify(path)}, { method: 'POST', headers: { 'Content-Type': ${JSON.stringify(contentType)} }, body: ${JSON.stringify(body)} }); return response.ok`
329+
return [{ kind: "navigate", url: "/", waitFor: "load" }, { kind: "evaluate", expression, assert: true }]
330+
}

tests/fixtures/wordpress-adversarial/vulnerable-plugin/vulnerable-plugin.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,22 @@
6262
));
6363
});
6464

65-
add_action('wp_ajax_wp_codebox_vulnerable_save', static function (): void {
65+
$wp_codebox_vulnerable_ajax = static function (): void {
6666
// Intentionally vulnerable: no check_ajax_referer() or capability check.
6767
update_option('wp_codebox_vulnerable_ajax', wp_unslash($_POST['value'] ?? ''), false);
6868
wp_send_json_success(array('violations' => array('ajax-nonce-bypass')));
69+
};
70+
add_action('wp_ajax_wp_codebox_vulnerable_save', $wp_codebox_vulnerable_ajax);
71+
add_action('wp_ajax_nopriv_wp_codebox_vulnerable_save', $wp_codebox_vulnerable_ajax);
72+
73+
function wp_codebox_adversarial_vulnerable_xmlrpc($args): array {
74+
update_option('wp_codebox_vulnerable_xmlrpc', $args, false);
75+
return array('saved' => true, 'violations' => array('xmlrpc-authorization-bypass'));
76+
}
77+
78+
add_filter('xmlrpc_methods', static function (array $methods): array {
79+
$methods['wpCodebox.vulnerable'] = 'wp_codebox_adversarial_vulnerable_xmlrpc';
80+
return $methods;
6981
});
7082

7183
add_shortcode('wp_codebox_vulnerable', static function (array $attributes): string {

tests/wordpress-adversarial-adapter.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,21 @@ import {
1717
const adapter = createWordPressAdversarialAdapter()
1818
assert.equal(adapter.schema, "wp-codebox/wordpress-adversarial-adapter/v1")
1919
assert.equal(WORDPRESS_ADVERSARIAL_CAPABILITIES.find(({ surface }) => surface === "rest")?.fidelity, "exact")
20-
assert.equal(WORDPRESS_ADVERSARIAL_CAPABILITIES.find(({ surface }) => surface === "ajax")?.fidelity, "unsupported")
20+
assert.equal(WORDPRESS_ADVERSARIAL_CAPABILITIES.find(({ surface }) => surface === "ajax")?.fidelity, "exact")
21+
assert.equal(WORDPRESS_ADVERSARIAL_CAPABILITIES.find(({ surface }) => surface === "xmlrpc")?.fidelity, "exact")
2122
assert.equal(WORDPRESS_CLOCK_CONTROL_CAPABILITIES.capabilities.find(({ surface }) => surface === "runtime")?.fidelity, "unsupported")
2223
assert.equal(WORDPRESS_CLOCK_CONTROL_CAPABILITIES.capabilities.find(({ surface }) => surface === "scheduler")?.fidelity, "emulated")
2324

2425
const rest = wordpressAdversarialActionSpec({ surface: "rest", operation: "POST", target: "/fixture/v1/action", input: { value: "mutated" } })
2526
assert.equal(rest.command, "wordpress.run-php")
2627
assert.equal(rest.metadata?.fidelity, "exact")
2728
assert.match(rest.args?.[0] ?? "", /WP_REST_Request/)
28-
assert.throws(() => wordpressAdversarialActionSpec({ surface: "xmlrpc", operation: "POST" }), /unsupported/)
29+
const ajax = wordpressAdversarialActionSpec({ surface: "ajax", operation: "POST", target: "fixture_save", input: { value: "mutated" } })
30+
assert.equal(ajax.command, "wordpress.browser-actions")
31+
assert.match(ajax.args?.find((arg) => arg.startsWith("steps-json=")) ?? "", /action=fixture_save&value=mutated/)
32+
const xmlrpc = wordpressAdversarialActionSpec({ surface: "xmlrpc", operation: "POST", input: "<methodCall><methodName>fixture.echo</methodName></methodCall>" })
33+
assert.equal(xmlrpc.command, "wordpress.browser-actions")
34+
assert.match(xmlrpc.args?.[0] ?? "", /xmlrpc\.php/)
2935

3036
const cli = wordpressAdversarialActionSpec({ surface: "cli", operation: "run", input: "option get home" })
3137
assert.deepEqual(cli.args, ["command=option get home"])

0 commit comments

Comments
 (0)