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 ` + + + + + + + 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('