From 0c2c85143ecf06356804e2f64637ff70a2b77bae Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Tue, 20 Oct 2026 12:17:52 -0300 Subject: [PATCH 1/8] Add /sandbox: try requests against CRS from the browser A page where visitors send a request to the CRS Sandbox and see which rules matched, on the engine, CRS version and paranoia level they pick. Built like the CRS 3->4 config migrator: a shortcode with inline markup, styles and script, no build step. The form covers method, path/query, body and content type, presets (path traversal, SQLi, XSS, command injection, scanner UA, JSON body, benign), engine and CRS version discovered from GET /backends, paranoia level, detection-only mode, detection PL, anomaly threshold, application exclusions and extra headers. The result shows the verdict (blocked, detected but not blocked, clean, edge error, no answer), engine and exact CRS tag, anomaly score, the matched rules linked to their rule files, the x-unique-id for support, a copyable curl equivalent and the raw json-report. static/_headers: connect-src gains https://sandbox.coreruleset.org, otherwise the CSP blocks the page's fetch on Cloudflare Pages. Menu entry under Documentation. Needs coreruleset/crs-sandbox#160 (CORS at the edge) deployed: the sandbox currently proxies preflights to the WAF, which blocks any preflight whose URL carries the attack. Co-Authored-By: Claude Fable 5.1 --- config/_default/languages.yaml | 6 + content/pages/sandbox.md | 54 ++++ layouts/shortcodes/crs-sandbox.html | 412 ++++++++++++++++++++++++++++ static/_headers | 2 +- 4 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 content/pages/sandbox.md create mode 100644 layouts/shortcodes/crs-sandbox.html diff --git a/config/_default/languages.yaml b/config/_default/languages.yaml index bbfac581..de9876dd 100644 --- a/config/_default/languages.yaml +++ b/config/_default/languages.yaml @@ -75,6 +75,12 @@ en: weight: 3 post: "Learn how to contribute" parent: "docs" + - identifier: "sandbox" + name: "Sandbox" + url: "/sandbox/" + weight: 4 + post: "Try payloads against CRS live" + parent: "docs" - identifier: "faq" name: "FAQs" url: "/faq/" diff --git a/content/pages/sandbox.md b/content/pages/sandbox.md new file mode 100644 index 00000000..23093b19 --- /dev/null +++ b/content/pages/sandbox.md @@ -0,0 +1,54 @@ +--- +title: Sandbox +description: Send a request to the CRS Sandbox and see which rules it triggers on Apache, Nginx or Coraza, with the CRS version and paranoia level of your choice. +url: /sandbox +--- + +The [CRS Sandbox](https://github.com/coreruleset/crs-sandbox) is a public instance of OWASP CRS running on +three WAF engines ([ModSecurity](https://github.com/owasp-modsecurity/ModSecurity) 2 on Apache, +ModSecurity 3 on Nginx, and [Coraza](https://coraza.io/)) in several CRS versions. Send it a request, +pick the engine, version and paranoia level with headers, and get back the rules that matched instead of the page. + +Use the form below, or the headers directly from the command line further down. + +{{< crs-sandbox >}} + +## From the command line + +Every option in the form is a request header. Send the request to `https://sandbox.coreruleset.org` +with any path, method, query string or body you like: + +```shell +curl -H "x-backend: nginx" \ + -H "x-crs-version: latest" \ + -H "x-crs-paranoia-level: 2" \ + -H "x-format-output: txt-matched-rules" \ + "https://sandbox.coreruleset.org/get?file=../../etc/passwd" +``` + +| Header | Values | Default | +|--------|--------|---------| +| `x-backend` | `apache`, `nginx`, `coraza` | `apache` | +| `x-crs-version` | `v4-lts`, `v3-lts`, `latest` (`coraza` has no `v3-lts`) | `v4-lts` | +| `x-crs-paranoia-level` | `1` to `4`, blocking and detection | `1` | +| `x-crs-detection-paranoia-level` | `1` to `4`, detection only | same as above | +| `x-crs-inbound-anomaly-score-threshold` | number | `5` | +| `x-crs-mode` | `detection` for DetectionOnly, never blocks | blocking | +| `x-crs-exclusions-` | `1` for `cpanel`, `drupal`, `dokuwiki`, `nextcloud`, `wordpress`, `xenforo` | off | +| `x-format-output` | `json-matched-rules`, `json-report`, `csv-matched-rules`, `txt-matched-rules`, `txt-matched-rules-extended`, `html-matched-rules` | raw audit log | + +`GET https://sandbox.coreruleset.org/backends` returns the supported combinations as JSON. +The response carries `x-backend` with the service that answered and `x-crs-version` with the exact +CRS tag it runs, so `latest` always resolves to something you can check. + +## Good to know + +- The response body is the WAF's verdict, not the page you asked for. Behind every WAF is a small + HTTP mirror ([go-httpbin](https://github.com/mccutchen/go-httpbin)) that accepts anything, so you + can use any path and any method. +- A `403` with a blocking rule (`949110`) in the list means CRS blocked the request. In detection + mode, or when the anomaly score stays below the threshold, you get the matched rules with a `200`. +- Requests are logged in full. Do not send real credentials or personal data. +- Something broken? Open an issue on the + [crs-sandbox repository](https://github.com/coreruleset/crs-sandbox/issues) and include the + `x-unique-id` shown with the result. diff --git a/layouts/shortcodes/crs-sandbox.html b/layouts/shortcodes/crs-sandbox.html new file mode 100644 index 00000000..4559c4af --- /dev/null +++ b/layouts/shortcodes/crs-sandbox.html @@ -0,0 +1,412 @@ +{{- /* + CRS Sandbox playground: sends the visitor's request to sandbox.coreruleset.org + with the CRS control headers and renders the json-report answer. + Parameters: api (default https://sandbox.coreruleset.org), e.g. for a local + sandbox: {{< crs-sandbox api="http://localhost:8088" >}} + The sandbox answers CORS for any origin; the site's CSP (static/_headers) + must list it under connect-src. +*/ -}} +
+ + +

Try a request against CRS

+

Pick a payload, an engine and a paranoia level. The request is sent from your browser to + sandbox.coreruleset.org; what comes back is the list of rules that matched.

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ More options +
+
+ + +
+
+ + +
+
+ +
+ + + + + + +
+
+
+ + +
+
+
+ +
+ + +
+ +
+
+ + +
+
+
+
+ Reproduce with curl +

+      
+    
+
+ Raw json-report +

+    
+
+

Requests are logged in full on the sandbox. Do not send real credentials or personal data.

+
+ +' }, + rce: { method: 'GET', path: '/get?cmd=;cat /etc/passwd' }, + scanner: { method: 'GET', path: '/get', headers: 'User-Agent: Nikto/2.1.6' }, + json: { method: 'POST', path: '/post', ctype: 'application/json', body: '{"user":"admin\' OR 1=1--","pass":"x"}' }, + benign: { method: 'GET', path: '/get?page=2&sort=name' } + }; + + // CRS 4 rule files by id prefix, for the links in the result table. + var RULE_FILES = { + '911': 'REQUEST-911-METHOD-ENFORCEMENT', '913': 'REQUEST-913-SCANNER-DETECTION', + '920': 'REQUEST-920-PROTOCOL-ENFORCEMENT', '921': 'REQUEST-921-PROTOCOL-ATTACK', + '922': 'REQUEST-922-MULTIPART-ATTACK', '930': 'REQUEST-930-APPLICATION-ATTACK-LFI', + '931': 'REQUEST-931-APPLICATION-ATTACK-RFI', '932': 'REQUEST-932-APPLICATION-ATTACK-RCE', + '933': 'REQUEST-933-APPLICATION-ATTACK-PHP', '934': 'REQUEST-934-APPLICATION-ATTACK-GENERIC', + '941': 'REQUEST-941-APPLICATION-ATTACK-XSS', '942': 'REQUEST-942-APPLICATION-ATTACK-SQLI', + '943': 'REQUEST-943-APPLICATION-ATTACK-SESSION-FIXATION', '944': 'REQUEST-944-APPLICATION-ATTACK-JAVA', + '949': 'REQUEST-949-BLOCKING-EVALUATION', '950': 'RESPONSE-950-DATA-LEAKAGES', + '951': 'RESPONSE-951-DATA-LEAKAGES-SQL', '952': 'RESPONSE-952-DATA-LEAKAGES-JAVA', + '953': 'RESPONSE-953-DATA-LEAKAGES-PHP', '954': 'RESPONSE-954-DATA-LEAKAGES-IIS', + '955': 'RESPONSE-955-WEB-SHELLS', '959': 'RESPONSE-959-BLOCKING-EVALUATION', + '980': 'RESPONSE-980-CORRELATION' + }; + + var engines = {}; // name -> [versions] + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + + function hasBody(method) { return !/^(GET|HEAD|OPTIONS)$/.test(method); } + + function fillVersions() { + var sel = $('sb-version'), current = sel.value; + var versions = engines[$('sb-engine').value] || ['v4-lts']; + sel.innerHTML = ''; + versions.forEach(function (v) { + var o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); + }); + sel.value = versions.indexOf(current) >= 0 ? current : versions[0]; + } + + // /backends tells us what the sandbox supports today; the markup above is + // the fallback when it cannot be reached. + function discover() { + fetch(API + '/backends').then(function (r) { return r.json(); }).then(function (info) { + var sel = $('sb-engine'); sel.innerHTML = ''; + (info.engines || []).forEach(function (e) { + engines[e.name] = e.crs_versions; + var o = document.createElement('option'); o.value = e.name; o.textContent = e.name; sel.appendChild(o); + }); + sel.value = (info.defaults && info.defaults.backend) || sel.options[0].value; + fillVersions(); + if (info.targets && info.targets.length > 1) { + var t = $('sb-target'); t.innerHTML = ''; + info.targets.forEach(function (tg) { + var o = document.createElement('option'); o.value = tg.name; + o.textContent = tg.name + (tg.engines === 'all' ? '' : ' (' + tg.engines.map(function (e) { + return e.name + ' ' + e.crs_versions.join('/'); + }).join(', ') + ')'); + t.appendChild(o); + }); + $('sb-target-wrap').style.display = ''; + } + }).catch(function () { /* keep the static defaults */ }); + } + + function parseExtraHeaders() { + var out = []; + $('sb-headers').value.split('\n').forEach(function (line) { + var i = line.indexOf(':'); + if (i > 0) out.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]); + }); + return out; + } + + function buildRequest() { + var method = $('sb-method').value; + var path = $('sb-path').value.trim() || '/'; + if (path[0] !== '/') path = '/' + path; + var headers = [ + ['x-backend', $('sb-engine').value], + ['x-crs-version', $('sb-version').value], + ['x-format-output', 'json-report'] + ]; + if ($('sb-pl').value !== '1') headers.push(['x-crs-paranoia-level', $('sb-pl').value]); + if ($('sb-dpl').value) headers.push(['x-crs-detection-paranoia-level', $('sb-dpl').value]); + if ($('sb-threshold').value) headers.push(['x-crs-inbound-anomaly-score-threshold', $('sb-threshold').value]); + if ($('sb-mode').value) headers.push(['x-crs-mode', $('sb-mode').value]); + if ($('sb-target-wrap').style.display !== 'none' && $('sb-target').value && $('sb-target').value !== 'httpbin') { + headers.push(['x-target', $('sb-target').value]); + } + root.querySelectorAll('#sb-exclusions input:checked').forEach(function (c) { + headers.push(['x-crs-exclusions-' + c.value, '1']); + }); + var body = null; + if (hasBody(method)) { + body = $('sb-body').value; + headers.push(['Content-Type', $('sb-ctype').value]); + } + parseExtraHeaders().forEach(function (h) { headers.push(h); }); + return { method: method, path: path, headers: headers, body: body }; + } + + function shellQuote(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; } + + function curlFor(req) { + var parts = ['curl -i']; + if (req.method !== 'GET') parts.push('-X ' + req.method); + req.headers.forEach(function (h) { + if (h[0].toLowerCase() === 'x-format-output') h = ['x-format-output', 'txt-matched-rules']; + parts.push('-H ' + shellQuote(h[0] + ': ' + h[1])); + }); + if (req.body !== null && req.body !== '') parts.push('--data ' + shellQuote(req.body)); + parts.push(shellQuote(API + req.path)); + return parts.join(' \\\n '); + } + + function ruleLink(id) { + var file = RULE_FILES[String(id).slice(0, 3)]; + if (!file) return esc(id); + return '' + esc(id) + ''; + } + + function render(req, status, headers, report) { + var badge = $('sb-badge'), summary = $('sb-summary'), rules = $('sb-rules'); + $('sb-result').style.display = ''; + $('sb-curl').textContent = curlFor(req); + $('sb-raw').textContent = JSON.stringify(report, null, 2); + var meta = []; + if (headers.get('x-backend')) meta.push('answered by ' + headers.get('x-backend')); + if (headers.get('x-crs-version')) meta.push('CRS ' + headers.get('x-crs-version')); + if (headers.get('x-unique-id')) meta.push('x-unique-id ' + headers.get('x-unique-id')); + $('sb-meta').textContent = meta.join(' · '); + + if (report && report.error && report.error.source === 'sandbox-edge') { + badge.className = 'sb-badge error'; badge.textContent = 'EDGE ERROR'; + summary.textContent = 'HTTP ' + status + ': ' + report.error.detail; + rules.innerHTML = ''; + return; + } + if (!report || !Array.isArray(report.matched_rules)) { + badge.className = 'sb-badge error'; badge.textContent = 'NO VERDICT'; + summary.textContent = 'HTTP ' + status + ': ' + (report && report.error ? report.error : 'unexpected answer from the sandbox'); + rules.innerHTML = ''; + return; + } + + var matched = report.matched_rules; + if (report.blocked) { + badge.className = 'sb-badge blocked'; badge.textContent = 'BLOCKED'; + } else if (matched.length) { + badge.className = 'sb-badge detected'; badge.textContent = 'DETECTED, NOT BLOCKED'; + } else { + badge.className = 'sb-badge clean'; badge.textContent = 'NO RULES MATCHED'; + } + var s = 'HTTP ' + report.status + ' from ' + report.engine + ' with CRS ' + (report.crs_version_tag || report.crs_version); + if (report.anomaly_scores && report.anomaly_scores.inbound) { + var ib = report.anomaly_scores.inbound; + s += ', inbound anomaly score ' + (ib.blocking !== undefined ? ib.blocking : ib.detection); + } + if (report.paranoia_level) s += ', lowest paranoia level ' + report.paranoia_level; + summary.textContent = s + '.'; + + if (!matched.length) { rules.innerHTML = ''; return; } + var html = ''; + matched.forEach(function (m) { + html += ''; + }); + rules.innerHTML = html + '
RulePLMessageMatched data
' + ruleLink(m.id) + '' + esc(m.paranoia_level || '') + '' + + esc(m.message || '') + '' + esc((m.data || '').replace(/^Matched Data:\s*/, '')) + '
'; + } + + function send() { + var req = buildRequest(); + var btn = $('sb-send'); + btn.disabled = true; $('sb-status').textContent = 'Sending…'; + var init = { method: req.method, headers: {}, cache: 'no-store', redirect: 'manual' }; + req.headers.forEach(function (h) { init.headers[h[0]] = h[1]; }); + if (req.body !== null) init.body = req.body; + fetch(API + req.path, init).then(function (r) { + return r.text().then(function (text) { + var report = null; + try { report = JSON.parse(text); } catch (e) { report = { error: text.slice(0, 300) || ('empty body, HTTP ' + r.status) }; } + render(req, r.status, r.headers, report); + $('sb-status').textContent = ''; + }); + }).catch(function (err) { + $('sb-result').style.display = ''; + $('sb-badge').className = 'sb-badge error'; $('sb-badge').textContent = 'NO ANSWER'; + $('sb-summary').innerHTML = '' + esc(err.message) + ''; + $('sb-meta').textContent = 'The browser could not reach ' + API + '. The request never got a response (the sandbox may be down, or something between you and it dropped it).'; + $('sb-rules').innerHTML = ''; + $('sb-curl').textContent = curlFor(req); + $('sb-raw').textContent = ''; + $('sb-status').textContent = ''; + }).then(function () { btn.disabled = false; }); + } + + $('sb-method').addEventListener('change', function () { + $('sb-body-wrap').style.display = hasBody(this.value) ? '' : 'none'; + }); + $('sb-engine').addEventListener('change', fillVersions); + $('sb-preset').addEventListener('change', function () { + var p = PRESETS[this.value]; if (!p) return; + $('sb-method').value = p.method; + $('sb-path').value = p.path; + $('sb-body').value = p.body || ''; + if (p.ctype) $('sb-ctype').value = p.ctype; + $('sb-headers').value = p.headers || ''; + $('sb-body-wrap').style.display = hasBody(p.method) ? '' : 'none'; + }); + $('sb-path').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); send(); } }); + $('sb-send').addEventListener('click', send); + $('sb-copy').addEventListener('click', function () { + var b = this; + navigator.clipboard.writeText($('sb-curl').textContent).then(function () { + b.textContent = 'Copied'; setTimeout(function () { b.textContent = 'Copy'; }, 1500); + }); + }); + + discover(); +})(); + diff --git a/static/_headers b/static/_headers index ee3f88ca..bf28b9e8 100644 --- a/static/_headers +++ b/static/_headers @@ -4,7 +4,7 @@ Referrer-Policy: no-referrer Permissions-Policy: microphone=(), geolocation=(), camera=() - Content-Security-Policy: script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com/beacon.min.js https://coreruleset.org/ https://*.website-1u6.pages.dev/; connect-src 'self' https://cloudflareinsights.com; frame-ancestors 'none'; + Content-Security-Policy: script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com/beacon.min.js https://coreruleset.org/ https://*.website-1u6.pages.dev/; connect-src 'self' https://cloudflareinsights.com https://sandbox.coreruleset.org; frame-ancestors 'none'; https://:project.pages.dev/* X-Robots-Tag: noindex From 5e42cf6d53b0a12056468484817ac80286e451f7 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Tue, 20 Oct 2026 13:18:41 -0300 Subject: [PATCH 2/8] sandbox: drop the application exclusions checkboxes The CRS plugin exclusions only make sense with the matching application behind the WAF; against the HTTP mirror they add nothing but confusion. Co-Authored-By: Claude Fable 5.1 --- layouts/shortcodes/crs-sandbox.html | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/layouts/shortcodes/crs-sandbox.html b/layouts/shortcodes/crs-sandbox.html index 4559c4af..5b6a89a3 100644 --- a/layouts/shortcodes/crs-sandbox.html +++ b/layouts/shortcodes/crs-sandbox.html @@ -17,8 +17,6 @@ #crs-sandbox .sb-row{display:flex;gap:0.6rem;align-items:flex-end;flex-wrap:wrap} #crs-sandbox .sb-row > *{flex:1 1 8rem} #crs-sandbox .sb-row .sb-method{flex:0 0 7rem} - #crs-sandbox .sb-checks{display:flex;flex-wrap:wrap;gap:0.3rem 1rem;font-weight:400} - #crs-sandbox .sb-checks label{display:inline-flex;align-items:center;gap:0.3rem;font-weight:400;margin:0} #crs-sandbox details{margin-top:0.8rem} #crs-sandbox summary{cursor:pointer;font-weight:600;font-size:0.9rem} #crs-sandbox .sb-actions{display:flex;gap:0.6rem;flex-wrap:wrap;align-items:center;margin-top:1rem} @@ -132,17 +130,6 @@

Try a request against CRS

-
- -
- - - - - - -
-
@@ -278,9 +265,6 @@

Try a request against CRS

if ($('sb-target-wrap').style.display !== 'none' && $('sb-target').value && $('sb-target').value !== 'httpbin') { headers.push(['x-target', $('sb-target').value]); } - root.querySelectorAll('#sb-exclusions input:checked').forEach(function (c) { - headers.push(['x-crs-exclusions-' + c.value, '1']); - }); var body = null; if (hasBody(method)) { body = $('sb-body').value; From e598ff9f7b505d2e2c3f715322cdc1c452cf79df Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Tue, 20 Oct 2026 13:21:18 -0300 Subject: [PATCH 3/8] sandbox: drop the exclusions header from the table too Co-Authored-By: Claude Fable 5.1 --- content/pages/sandbox.md | 1 - 1 file changed, 1 deletion(-) diff --git a/content/pages/sandbox.md b/content/pages/sandbox.md index 23093b19..02da8bc0 100644 --- a/content/pages/sandbox.md +++ b/content/pages/sandbox.md @@ -34,7 +34,6 @@ curl -H "x-backend: nginx" \ | `x-crs-detection-paranoia-level` | `1` to `4`, detection only | same as above | | `x-crs-inbound-anomaly-score-threshold` | number | `5` | | `x-crs-mode` | `detection` for DetectionOnly, never blocks | blocking | -| `x-crs-exclusions-` | `1` for `cpanel`, `drupal`, `dokuwiki`, `nextcloud`, `wordpress`, `xenforo` | off | | `x-format-output` | `json-matched-rules`, `json-report`, `csv-matched-rules`, `txt-matched-rules`, `txt-matched-rules-extended`, `html-matched-rules` | raw audit log | `GET https://sandbox.coreruleset.org/backends` returns the supported combinations as JSON. From 0bf6fd1780e7c195df339a150643be4884b95e29 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Tue, 20 Oct 2026 13:24:05 -0300 Subject: [PATCH 4/8] sandbox: point issues at coreruleset/coreruleset, drop the private repo link The crs-sandbox repository is private, so visitors can neither open an issue there nor follow the link. Co-Authored-By: Claude Fable 5.1 --- content/pages/sandbox.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/content/pages/sandbox.md b/content/pages/sandbox.md index 02da8bc0..1c28eca6 100644 --- a/content/pages/sandbox.md +++ b/content/pages/sandbox.md @@ -4,7 +4,7 @@ description: Send a request to the CRS Sandbox and see which rules it triggers o url: /sandbox --- -The [CRS Sandbox](https://github.com/coreruleset/crs-sandbox) is a public instance of OWASP CRS running on +The CRS Sandbox is a public instance of OWASP CRS running on three WAF engines ([ModSecurity](https://github.com/owasp-modsecurity/ModSecurity) 2 on Apache, ModSecurity 3 on Nginx, and [Coraza](https://coraza.io/)) in several CRS versions. Send it a request, pick the engine, version and paranoia level with headers, and get back the rules that matched instead of the page. @@ -48,6 +48,5 @@ CRS tag it runs, so `latest` always resolves to something you can check. - A `403` with a blocking rule (`949110`) in the list means CRS blocked the request. In detection mode, or when the anomaly score stays below the threshold, you get the matched rules with a `200`. - Requests are logged in full. Do not send real credentials or personal data. -- Something broken? Open an issue on the - [crs-sandbox repository](https://github.com/coreruleset/crs-sandbox/issues) and include the +- Something broken? [Open an issue](https://github.com/coreruleset/coreruleset/issues) and include the `x-unique-id` shown with the result. From 97e3b6a18f750c590314f60748c9c57e7ee0c9ca Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Tue, 20 Oct 2026 13:37:35 -0300 Subject: [PATCH 5/8] sandbox: be honest about what the browser actually sends The browser rewrites the URL before fetch() sends it: "#fragments" are dropped, ".." segments and backslashes resolved, control characters removed, most special characters percent-encoded. It also silently drops forbidden request headers (Cookie, Host, Referer, Origin, Sec-*...), and Safari drops User-Agent too. None of that was visible on the page, and the curl line was built from the typed text, so curl and the browser could legitimately disagree. - the result shows "Sent as " whenever the browser changed what was typed, and the curl line uses that URL so it reproduces the request - a warning names the extra headers the browser will not send, and notes the Safari User-Agent case; the placeholder no longer suggests Cookie - a fetch() refusal (header value with a line break, bad URL) is reported as BROWSER REFUSED with an explanation, distinct from an unreachable sandbox - redirects are followed (redirect: 'manual' gave an opaque status-0 response) and the result says it shows the verdict of the final request - a "Good to know" bullet explains the normalisation and points to curl (--path-as-is) for raw bytes Co-Authored-By: Claude Fable 5.1 --- content/pages/sandbox.md | 5 ++ layouts/shortcodes/crs-sandbox.html | 102 +++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/content/pages/sandbox.md b/content/pages/sandbox.md index 1c28eca6..3ff7f41a 100644 --- a/content/pages/sandbox.md +++ b/content/pages/sandbox.md @@ -47,6 +47,11 @@ CRS tag it runs, so `latest` always resolves to something you can check. can use any path and any method. - A `403` with a blocking rule (`949110`) in the list means CRS blocked the request. In detection mode, or when the anomaly score stays below the threshold, you get the matched rules with a `200`. +- The form sends what a browser can send. Before the request leaves the browser, `#fragments` are + dropped, `..` path segments and backslashes are resolved, control characters are removed and most + special characters are percent-encoded; the result shows the URL that was actually sent. Headers + such as `Cookie`, `Host` or `Referer` cannot be set from a page at all. For raw bytes use curl + (`--path-as-is` keeps `..` segments). - Requests are logged in full. Do not send real credentials or personal data. - Something broken? [Open an issue](https://github.com/coreruleset/coreruleset/issues) and include the `x-unique-id` shown with the result. diff --git a/layouts/shortcodes/crs-sandbox.html b/layouts/shortcodes/crs-sandbox.html index 5b6a89a3..98137635 100644 --- a/layouts/shortcodes/crs-sandbox.html +++ b/layouts/shortcodes/crs-sandbox.html @@ -39,6 +39,8 @@ #crs-sandbox pre{background:#f6f8fa;border:1px solid #d0d7de;border-radius:6px;padding:0.75rem;overflow:auto;font-size:0.8em;margin:0.4rem 0} #crs-sandbox .sb-error{color:#cf222e;white-space:pre-wrap} #crs-sandbox .sb-note{font-size:0.8rem;color:#57606a;margin-top:0.6rem} + #crs-sandbox .sb-warn{display:none;margin-top:0.8rem;padding:0.6rem 0.8rem;border:1px solid #d4a72c;border-radius:6px;background:#fff8c5;font-size:0.85rem} + #crs-sandbox .sb-sent{margin:0.3rem 0 0.8rem;font-size:0.85rem;color:#57606a;word-break:break-all}

Try a request against CRS

@@ -132,7 +134,8 @@

Try a request against CRS

- + +
Browsers refuse to send some headers from a page (Cookie, Host, Referer, Origin, Sec-*…); those are dropped silently, and Safari also drops User-Agent. Use curl for them.
@@ -141,6 +144,7 @@

Try a request against CRS

+
@@ -148,6 +152,7 @@

Try a request against CRS

+
Reproduce with curl @@ -197,6 +202,17 @@

Try a request against CRS

var engines = {}; // name -> [versions] + // Request headers a page is not allowed to set (Fetch "forbidden request + // header"); the browser drops them without a word. + var FORBIDDEN = ['accept-charset', 'accept-encoding', 'access-control-request-headers', + 'access-control-request-method', 'connection', 'content-length', 'cookie', 'cookie2', 'date', + 'dnt', 'expect', 'host', 'keep-alive', 'origin', 'referer', 'set-cookie', 'te', 'trailer', + 'transfer-encoding', 'upgrade', 'via']; + function isForbidden(name) { + var n = name.toLowerCase(); + return FORBIDDEN.indexOf(n) >= 0 || n.indexOf('proxy-') === 0 || n.indexOf('sec-') === 0; + } + function esc(s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; @@ -249,6 +265,20 @@

Try a request against CRS

return out; } + function updateWarnings() { + var dropped = [], ua = false; + parseExtraHeaders().forEach(function (h) { + if (isForbidden(h[0])) dropped.push(h[0]); + if (h[0].toLowerCase() === 'user-agent') ua = true; + }); + var msgs = []; + if (dropped.length) msgs.push('The browser will not send ' + dropped.map(function (n) { return '' + esc(n) + ''; }).join(', ') + ': pages are not allowed to set these headers. Use curl.'); + if (ua) msgs.push('User-Agent is sent by Chrome and Firefox but dropped by Safari.'); + var w = $('sb-warn'); + w.innerHTML = msgs.join('
'); + w.style.display = msgs.length ? '' : 'none'; + } + function buildRequest() { var method = $('sb-method').value; var path = $('sb-path').value.trim() || '/'; @@ -271,7 +301,12 @@

Try a request against CRS

headers.push(['Content-Type', $('sb-ctype').value]); } parseExtraHeaders().forEach(function (h) { headers.push(h); }); - return { method: method, path: path, headers: headers, body: body }; + // The browser normalises the URL before sending it: "..", backslashes, + // control characters and "#fragments" never reach the WAF, and most + // special characters get percent-encoded. Keep both forms to show the + // difference and to make the curl line reproduce the real request. + var url = new URL(API + path); + return { method: method, path: path, sent: url.pathname + url.search, url: url.href, headers: headers, body: body }; } function shellQuote(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; } @@ -284,7 +319,7 @@

Try a request against CRS

parts.push('-H ' + shellQuote(h[0] + ': ' + h[1])); }); if (req.body !== null && req.body !== '') parts.push('--data ' + shellQuote(req.body)); - parts.push(shellQuote(API + req.path)); + parts.push(shellQuote(req.url)); return parts.join(' \\\n '); } @@ -294,12 +329,24 @@

Try a request against CRS

return '' + esc(id) + ''; } - function render(req, status, headers, report) { + function showSent(req) { + var el = $('sb-sent'); + if (req.sent !== req.path) { + el.innerHTML = 'Sent as ' + esc(req.sent) + ': the browser normalised what you typed before sending it.'; + } else { + el.innerHTML = ''; + } + } + + function render(req, resp, report) { + var status = resp.status, headers = resp.headers; var badge = $('sb-badge'), summary = $('sb-summary'), rules = $('sb-rules'); $('sb-result').style.display = ''; $('sb-curl').textContent = curlFor(req); $('sb-raw').textContent = JSON.stringify(report, null, 2); + showSent(req); var meta = []; + if (resp.redirected) meta.push('the browser followed a redirect to ' + resp.url + ', this is the verdict for that request'); if (headers.get('x-backend')) meta.push('answered by ' + headers.get('x-backend')); if (headers.get('x-crs-version')) meta.push('CRS ' + headers.get('x-crs-version')); if (headers.get('x-unique-id')) meta.push('x-unique-id ' + headers.get('x-unique-id')); @@ -343,29 +390,49 @@

Try a request against CRS

rules.innerHTML = html + ''; } + function failed(req, badgeText, message, explanation) { + $('sb-result').style.display = ''; + $('sb-badge').className = 'sb-badge error'; $('sb-badge').textContent = badgeText; + $('sb-summary').innerHTML = '' + esc(message) + ''; + $('sb-meta').textContent = explanation; + $('sb-sent').innerHTML = ''; + $('sb-rules').innerHTML = ''; + $('sb-curl').textContent = req ? curlFor(req) : ''; + $('sb-raw').textContent = ''; + $('sb-status').textContent = ''; + } + function send() { - var req = buildRequest(); - var btn = $('sb-send'); + updateWarnings(); + var btn = $('sb-send'), req; + try { + req = buildRequest(); + } catch (e) { + failed(null, 'BROWSER REFUSED', e.message, 'The browser could not turn that into a URL, so nothing was sent.'); + return; + } btn.disabled = true; $('sb-status').textContent = 'Sending…'; - var init = { method: req.method, headers: {}, cache: 'no-store', redirect: 'manual' }; + var init = { method: req.method, headers: {}, cache: 'no-store', redirect: 'follow' }; req.headers.forEach(function (h) { init.headers[h[0]] = h[1]; }); if (req.body !== null) init.body = req.body; - fetch(API + req.path, init).then(function (r) { + var promise; + try { promise = fetch(req.url, init); } catch (e) { promise = Promise.reject(e); } + promise.then(function (r) { return r.text().then(function (text) { var report = null; try { report = JSON.parse(text); } catch (e) { report = { error: text.slice(0, 300) || ('empty body, HTTP ' + r.status) }; } - render(req, r.status, r.headers, report); + render(req, r, report); $('sb-status').textContent = ''; }); }).catch(function (err) { - $('sb-result').style.display = ''; - $('sb-badge').className = 'sb-badge error'; $('sb-badge').textContent = 'NO ANSWER'; - $('sb-summary').innerHTML = '' + esc(err.message) + ''; - $('sb-meta').textContent = 'The browser could not reach ' + API + '. The request never got a response (the sandbox may be down, or something between you and it dropped it).'; - $('sb-rules').innerHTML = ''; - $('sb-curl').textContent = curlFor(req); - $('sb-raw').textContent = ''; - $('sb-status').textContent = ''; + // A network failure reads "Failed to fetch" / "NetworkError…" / "Load + // failed" depending on the browser; anything else is the browser + // refusing to build the request (bad header value, forbidden method). + if (/failed to fetch|fetch failed|networkerror|load failed|network/i.test(err.message)) { + failed(req, 'NO ANSWER', err.message, 'The browser could not reach ' + API + '. The request never got a response (the sandbox may be down, or something between you and it dropped it).'); + } else { + failed(req, 'BROWSER REFUSED', err.message, 'The browser refused to build this request, so nothing was sent. Header values cannot contain line breaks or non-Latin-1 characters; use curl for those.'); + } }).then(function () { btn.disabled = false; }); } @@ -383,6 +450,7 @@

Try a request against CRS

$('sb-body-wrap').style.display = hasBody(p.method) ? '' : 'none'; }); $('sb-path').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); send(); } }); + $('sb-headers').addEventListener('input', updateWarnings); $('sb-send').addEventListener('click', send); $('sb-copy').addEventListener('click', function () { var b = this; From 675eb0f071dbb865986eae1469a146d82319e0b6 Mon Sep 17 00:00:00 2001 From: Felipe Zipitria Date: Tue, 20 Oct 2026 15:16:10 -0300 Subject: [PATCH 6/8] sandbox: keep the XSS preset from closing the inline script The preset payload contained a literal , which ends the script element in every browser: the rest of the page script was rendered as text and nothing worked on the Cloudflare preview. Escape the slash so the HTML parser leaves it alone; Hugo's minifier preserves the escape. Co-Authored-By: Claude Fable 5.1 --- layouts/shortcodes/crs-sandbox.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/layouts/shortcodes/crs-sandbox.html b/layouts/shortcodes/crs-sandbox.html index 98137635..a2fb076e 100644 --- a/layouts/shortcodes/crs-sandbox.html +++ b/layouts/shortcodes/crs-sandbox.html @@ -177,7 +177,7 @@

Try a request against CRS

var PRESETS = { lfi: { method: 'GET', path: '/get?file=../../etc/passwd' }, sqli: { method: 'GET', path: "/get?id=1' OR '1'='1' --" }, - xss: { method: 'GET', path: '/get?q=' }, + xss: { method: 'GET', path: '/get?q=