From efa286d9854e02e0bdc878654a65d1a5a37ab651 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 15 Jul 2026 10:35:25 +0200 Subject: [PATCH] feat(protect): generic block message + branded HTML "Access Denied" page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two client-facing changes to request blocks: - De-narrativize the block text. The client body no longer says "Blocked by Patchstack WAF rule: " — it's a generic "This request has been blocked by Patchstack." with no WAF wording and no rule title. The opaque rule id is kept as a machine field (server-fn receipts / support reference), and the full reason still goes to the server log via onDetect. - Serve a branded full-page "Access Denied" view (ported from the Patchstack WordPress plugin's sample.php → src/protect/block-page.js, self-contained inline CSS + base64 SVGs) when a BLOCKED request is a top-level document navigation (Sec-Fetch-Dest: document, or Accept: text/html). XHR/fetch clients still get masked JSON. The URL is HTML-escaped (no reflected XSS); the page shows the opaque rule id as a reference code, no rule detail. Wired across the fetch/route-WAF, node, and express block paths. +4 tests (HTML vs JSON routing, generic message, XSS escaping). 427 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/protect/block-page.js | 89 ++++++++++++++++++++++++++++ src/protect/runtime.js | 50 ++++++++++++---- tests/protect/block-response.test.ts | 59 ++++++++++++++++++ 3 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 src/protect/block-page.js create mode 100644 tests/protect/block-response.test.ts diff --git a/src/protect/block-page.js b/src/protect/block-page.js new file mode 100644 index 0000000..e738b9a --- /dev/null +++ b/src/protect/block-page.js @@ -0,0 +1,89 @@ +// Full-page "Access Denied" block view, ported from the Patchstack WordPress plugin's +// sample.php. Served (instead of JSON) when a BLOCKED request is a top-level document navigation +// rather than an XHR/fetch — so a user who browses into a blocked URL sees a branded page, while +// programmatic clients still get JSON. Self-contained (inline CSS + base64 SVGs); exposes no WAF +// narrative and no rule details. + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); +} + +/** @param {{ url?: string, code?: string }} opts @returns {string} a complete HTML document */ +export function renderBlockPage({ url = '', code = 'patchstack' } = {}) { + const safeUrl = escapeHtml(url); + const safeCode = escapeHtml(code || 'patchstack'); + return `<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <style type="text/css"> + .access-denied,.site-url-text{letter-spacing:.01em;color:#fff}body{background:#111216}.main-container{display:flex;justify-content:center;flex-direction:column;align-items:center}.container{width:688px;height:456px;background:#1c1e22;border-radius:16px;margin-top:120px}.content{height:330px;margin:64px}.access-denied{font-family:Verdana;font-style:normal;font-weight:600;font-size:24px;line-height:32px;display:flex;align-items:center}.descp-text,.site-url{display:flex;align-items:center}.site-url{margin-top:32px;width:560px;height:64px;background:#26282c;border-radius:4px}.reason-code-text,.site-url-text{font-family:Verdana;font-style:normal;border:none;background:#26282c}.site-url-text{text-overflow: ellipsis;font-weight:400;font-size:16px;line-height:24px;margin-left:16px;width:85%}.copy-icon{width:19px;height:22px;margin-left:20px}.copy-icon:hover{cursor:pointer}.reason-code-text{font-weight:600;font-size:24px;line-height:32px;letter-spacing:.01em;color:#AFE614;margin-left:16px;width:85%}.descp-text,.return-text{font-size:16px;line-height:24px;letter-spacing:.01em;font-weight:400}.return-text:hover{color: #AFE614}.descp-text,.logoText,.return-text,.secured-text{font-family:Verdana;font-style:normal;color:#fff}.descp{margin-top:32px;width:560px}.bottom-second-containers,.bottom-text-containers{align-items:center;display:flex}.bottom-container{margin-top:40px;display:flex;width:688px}.bottom-second-containers{margin-left:auto}.arrow-vector{margin-left:20px}.return-text{text-align:center;margin-left:12.59px;text-decoration:none}.secured-text{font-weight:400;font-size:11.2px;line-height:16px;display:flex;align-items:center;letter-spacing:.02em;margin-right:8px}.logoText{font-weight:600;font-size:16px;line-height:24px;letter-spacing:.01em;flex:none;order:1;flex-grow:0}.patch-logo{width:120px}.mobile-bottom-container{margin-top:40px;width:372px;display:none}.mobile-bottom-second-containers{margin-top:64px;display:flex;margin-left:16.5px}@media only screen and (max-width:900px){.container{background:#111216;width:370px;height:auto;margin-top:0}.content{margin-left:16.5px}.descp,.site-url{width:340px}.site-url-text{font-weight:400;font-size:14.4px;line-height:20px;letter-spacing:.02em;width:80%}.copy-icon{margin-left:20px}.reason-code-text{font-size:16px;line-height:32px;letter-spacing:.02em;margin-left:16px;width:80%}.descp-text,.return-text{font-size:14.4px;line-height:20px}.descp-text{display:flex;align-items:center;letter-spacing:.02em}.bottom-container{display:none}.mobile-bottom-container{display:block}.return-text{text-align:center;letter-spacing:.01em;color:#fff;margin-left:9.73px}} + </style> + <title>Access Denied + + +
+
+
+ Access Denied +
+ + Copy Text +
+
+ + Copy Text +
+
+ This request has been blocked by Patchstack. If this message persists and you are a legitimate user, contact the site administrator with the reference above. +
+
+
+
+ +
+ This website is secured by + + + +
+
+ +
+ +
+ This website is secured by + + + +
+
+
+ + +`; +} diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 5589720..c3f1636 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -16,6 +16,7 @@ import { fromFetchRequest } from './engine/fetch.js'; import { fromNodeRequest } from './engine/node.js'; import { installEgressGuard } from './egress.js'; import { DEFAULT_RESPONSE_RULES, DEFAULT_EGRESS_RULES } from './defaults.js'; +import { renderBlockPage } from './block-page.js'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -227,7 +228,7 @@ export async function createProtection(options = {}) { onError?.(err); return null; // fail open } - return decide('request', result, () => blockResponse(result), () => null); + return decide('request', result, () => blockResponse(result, request), () => null); }; }, @@ -257,7 +258,13 @@ export async function createProtection(options = {}) { decide( 'request', result, - () => res.status(403).json(blockBody(result)), + () => { + if (isDocumentNavigation((n) => req.headers?.[n])) { + res.status(403).type('html').send(renderBlockPage({ url: req.originalUrl || req.url || '/', code: result?.rule?.id })); + } else { + res.status(403).json(blockBody(result)); + } + }, () => { if (exprOptions.screenResponses) wrapNodeResponse(res); next(); @@ -302,8 +309,13 @@ export async function createProtection(options = {}) { result, () => { res.statusCode = 403; - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify(blockBody(result))); + if (isDocumentNavigation((n) => req.headers?.[n])) { + res.setHeader('content-type', 'text/html; charset=utf-8'); + res.end(renderBlockPage({ url: req.url || '/', code: result?.rule?.id })); + } else { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(blockBody(result))); + } }, () => { // This guard consumed the request stream to screen it; re-expose the parsed @@ -489,15 +501,33 @@ function cacheRead(dir) { // --- responses ---------------------------------------------------------- +// Client-facing block text — deliberately generic: no WAF narrative, no rule title. The reason +// detail stays in the server-side log via onDetect. +const BLOCK_MESSAGE = 'This request has been blocked by Patchstack.'; + function blockBody(result) { - return { - error: 'Blocked by Patchstack', - rule: result.rule?.id, - message: result.message, - }; + // Human text is masked (no WAF narrative, no rule title). The opaque rule id stays for machine + // consumers (server-fn receipts, support reference); full rule detail lives in the server log. + return { error: BLOCK_MESSAGE, message: BLOCK_MESSAGE, rule: result?.rule?.id }; +} + +// A top-level document navigation (vs an XHR/fetch)? Browsers set Sec-Fetch-Dest on navigations; +// fall back to the Accept header. Governs whether a block returns the HTML page or JSON. +function isDocumentNavigation(getHeader) { + const dest = getHeader('sec-fetch-dest'); + if (dest) return dest === 'document'; + return (getHeader('accept') || '').includes('text/html'); } -function blockResponse(result) { +// Request-phase block. Serves the branded HTML "Access Denied" page to a browser navigation, and +// masked JSON to XHR/fetch/programmatic clients. +function blockResponse(result, request) { + if (request && isDocumentNavigation((n) => request.headers.get(n))) { + return new Response(renderBlockPage({ url: request.url, code: result?.rule?.id }), { + status: 403, + headers: { 'content-type': 'text/html; charset=utf-8' }, + }); + } return new Response(JSON.stringify(blockBody(result)), { status: 403, headers: { 'content-type': 'application/json' }, diff --git a/tests/protect/block-response.test.ts b/tests/protect/block-response.test.ts new file mode 100644 index 0000000..f22ba13 --- /dev/null +++ b/tests/protect/block-response.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; +import { renderBlockPage } from '../../src/protect/block-page.js'; + +// A blocked REQUEST returns the branded "Access Denied" HTML page to a browser navigation, and +// masked JSON to an XHR/fetch. Neither surface exposes the WAF narrative or the rule title. + +const rules = { + firewall: [ + { + id: 'demo-lfi', + title: 'Path traversal in a file parameter', + category: 'lfi', + rule_v2: [{ parameter: ['get.file'], mutations: ['urldecode'], match: { type: 'contains', value: '..' } }], + }, + ], + whitelists: [], + whitelist_keys: {}, +}; + +const blockedReq = (headers: Record) => + new Request('https://app.demo/read?file=../../etc/passwd', { headers }); + +describe('request block response', () => { + it('serves the HTML block page for a top-level navigation (Sec-Fetch-Dest: document)', async () => { + const p = await createProtection({ rules, mode: 'block' }); + const res: any = await p.fetchGuard()(blockedReq({ 'sec-fetch-dest': 'document' })); + expect(res.status).toBe(403); + expect(res.headers.get('content-type')).toContain('text/html'); + const html = await res.text(); + expect(html).toContain('Access Denied'); + expect(html).toContain('This request has been blocked by Patchstack'); + expect(/WAF/i.test(html)).toBe(false); // no WAF narrative + expect(html).toContain('demo-lfi'); // opaque reference code + }); + + it('falls back to the HTML page via Accept: text/html when Sec-Fetch-Dest is absent', async () => { + const p = await createProtection({ rules, mode: 'block' }); + const res: any = await p.fetchGuard()(blockedReq({ accept: 'text/html,application/xhtml+xml' })); + expect(res.headers.get('content-type')).toContain('text/html'); + }); + + it('serves masked JSON to an XHR/fetch (generic message, no WAF narrative, keeps opaque rule id)', async () => { + const p = await createProtection({ rules, mode: 'block' }); + const res: any = await p.fetchGuard()(blockedReq({ 'sec-fetch-dest': 'empty', accept: 'application/json' })); + expect(res.status).toBe(403); + expect(res.headers.get('content-type')).toContain('application/json'); + const body = await res.json(); + expect(body.error).toBe('This request has been blocked by Patchstack.'); + expect(/WAF/i.test(JSON.stringify(body))).toBe(false); + expect(body.rule).toBe('demo-lfi'); // machine-readable id retained + }); + + it('renderBlockPage escapes the URL (no reflected XSS)', () => { + const html = renderBlockPage({ url: 'https://app/?x=">', code: 'demo-lfi' }); + expect(html.includes('