diff --git a/package.json b/package.json index 0eb4583..f855c37 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" + }, + "./protect": { + "types": "./dist/protect.d.ts", + "import": "./dist/protect.js", + "require": "./dist/protect.cjs" } }, "bin": { diff --git a/scripts/copy-protect-templates.mjs b/scripts/copy-protect-templates.mjs index 98e02af..ca1ea7c 100644 --- a/scripts/copy-protect-templates.mjs +++ b/scripts/copy-protect-templates.mjs @@ -1,8 +1,13 @@ // Copy the runtime-guard templates next to the built CLI so `patchstack-connect protect` // can scaffold them. Runs AFTER tsup (post-build), so tsup's async .d.ts pass can't clobber // the .d.ts templates (which it does if we copy via tsup's onSuccess). -import { cpSync, mkdirSync } from 'node:fs'; +import { cpSync, mkdirSync, copyFileSync } from 'node:fs'; mkdirSync('dist/protect', { recursive: true }); cpSync('src/protect/templates', 'dist/protect/templates', { recursive: true }); console.log('copied protect templates -> dist/protect/templates'); + +// Ship the hand-authored declarations for the `@patchstack/connect/protect` subpath (the runtime +// is plain JS, so tsup doesn't emit these). Referenced by the "./protect" export's `types`. +copyFileSync('src/protect/protect.d.ts', 'dist/protect.d.ts'); +console.log('copied protect types -> dist/protect.d.ts'); diff --git a/src/cli.ts b/src/cli.ts index 0467161..030659f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -26,9 +26,9 @@ Usage: patchstack-connect status [options] Show current configuration patchstack-connect mark-build [options] Stamp built HTML with a production flag + build fingerprint (run as a postbuild step) - patchstack-connect protect [--manifest] Install runtime protection (the guard) into - a server-side JS app. --manifest only - regenerates the package manifest (prebuild). + patchstack-connect protect Install always-on runtime protection (the + guard) into a TanStack Start + Supabase app. + Covers the browser + server-function paths. patchstack-connect guide Print the full setup guide for AI coding agents (also at https://patchstack.com/install.txt) patchstack-connect help Print this message @@ -196,10 +196,10 @@ async function runScan(args: ParsedArgs): Promise { return 0; } -async function runProtectCommand(args: ParsedArgs): Promise { +async function runProtectCommand(_args: ParsedArgs): Promise { // Best-effort: like mark-build, this runs during builds and must never fail one. try { - runProtect(process.cwd(), { manifestOnly: args.flags.get('manifest') === true }); + runProtect(process.cwd()); } catch (err) { console.warn(`patchstack protect: skipped (${(err as Error).message}).`); } diff --git a/src/protect/engine/client.js b/src/protect/engine/client.js new file mode 100644 index 0000000..865b9f7 --- /dev/null +++ b/src/protect/engine/client.js @@ -0,0 +1,82 @@ +const DEFAULT_BASE_URL = 'https://api.patchstack.com'; +const DEFAULT_CACHE_TTL = 300_000; + +export class PatchstackRuleClient { + #token; + #baseUrl; + #cacheTtl; + #cache = null; + #cacheTime = null; + + constructor({ token, baseUrl, cacheTtl } = {}) { + this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN; + this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL ?? DEFAULT_BASE_URL; + this.#cacheTtl = cacheTtl ?? DEFAULT_CACHE_TTL; + + if (!this.#token) { + throw new Error('Patchstack WAF token is required. Pass { token } or set PATCHSTACK_WAF_TOKEN env var.'); + } + } + + async getRules() { + const now = Date.now(); + + if (this.#cache && this.#cacheTime && (now - this.#cacheTime) < this.#cacheTtl) { + return this.#cache; + } + + const url = `${this.#baseUrl}/api/get-rules/3`; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.#token}`, + 'LicenseID': '1' // Hard-coded, is never actually used but needed by the API + }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(30_000) + }); + + if (!response.ok) { + return { + success: false, + error: `API returned ${response.status}: ${response.statusText}`, + firewall: [], + whitelists: [], + whitelist_keys: {} + }; + } + + const data = await response.json(); + + const result = { + success: true, + firewall: Array.isArray(data.firewall) ? data.firewall : [], + whitelists: Array.isArray(data.whitelists) ? data.whitelists : [], + whitelist_keys: data.whitelist_keys ?? {} + }; + + this.#cache = result; + this.#cacheTime = now; + + return result; + } catch (err) { + return { + success: false, + error: err.name === 'TimeoutError' + ? 'Request timed out' + : err.message, + firewall: [], + whitelists: [], + whitelist_keys: {} + }; + } + } + + clearCache() { + this.#cache = null; + this.#cacheTime = null; + } +} diff --git a/src/protect/engine/engine.js b/src/protect/engine/engine.js new file mode 100644 index 0000000..9b3bba5 --- /dev/null +++ b/src/protect/engine/engine.js @@ -0,0 +1,387 @@ +import { RequestResolver } from './request.js'; +import { normalizeRequest } from './normalizer.js'; + +const REDOS_PATTERNS = [ + /\(\w\+\)\+/, + /\(\w\*\)\+/, + /\(\w\+\)\*/, + /\(\w\*\)\*/, + /\(\w\|\w\)\+/ +]; + +function safeRegExp(pattern) { + if (!pattern) { + return null; + } + + for (const dangerous of REDOS_PATTERNS) { + if (dangerous.test(pattern)) { + return null; + } + } + + const match = pattern.match(/^\/(.+?)\/([gimsuy]*)$/s); + if (!match) { + return null; + } + + try { + return new RegExp(match[1], match[2]); + } catch { + return null; + } +} + +// ctype_*/is_numeric compare their character-class result to the rule's expected +// `value`. Rules are almost always written `{ "type": "ctype_digit", "value": false }` +// meaning "flag when NOT of this class" — so the result must be compared to matchVal, +// not returned raw. Empty/absent values never match (mirrors engine-php's `$value != ''` +// guard), otherwise every missing parameter false-positives a `value:false` rule. +// When `value` is not a boolean (null/omitted) the legacy default of `true` is used, so +// existing rules that relied on the raw class check are unaffected. +function ctypeResult(strValue, isClass, matchVal) { + if (strValue === '') { + return false; + } + const expected = typeof matchVal === 'boolean' ? matchVal : true; + return isClass === expected; +} + +// array_key_value: navigate `match.key` (a dot-path, or an array of paths) inside the +// decoded value and run the nested `match.match` against whatever it finds. Mirrors +// engine-php's recursive array_key_value handling. +function arrayKeyValue(value, matchObj) { + if (!matchObj || !matchObj.match || value === null || typeof value !== 'object') { + return false; + } + const keys = Array.isArray(matchObj.key) ? matchObj.key : [matchObj.key]; + const sub = matchObj.match; + + for (const key of keys) { + let node = value; + for (const part of String(key).split('.')) { + if (node === null || typeof node !== 'object') { + node = undefined; + break; + } + node = node[part]; + } + if (node === undefined) { + continue; + } + const candidates = Array.isArray(node) ? node : [node]; + for (const candidate of candidates) { + if (matchValue(sub.type, candidate, sub.value, sub)) { + return true; + } + } + } + return false; +} + +// Report an unknown/removed match type once, so a rule referencing it is not silently +// unenforced (ADR: "unknown match type → skipped and logged, never silently passed"). +const warnedMatchTypes = new Set(); +function warnUnsupportedMatchType(type) { + if (type == null || warnedMatchTypes.has(type)) { + return; + } + warnedMatchTypes.add(type); + console.warn( + `[patchstack] Unsupported rule_v2 match type "${type}" — condition treated as no-match. ` + + `Rules relying on it are not enforced by this engine.` + ); +} + +// `matchObj` is the full match object; needed by types that read sibling fields +// (array_key_value reads `key`/`match`). Optional so direct callers/tests can keep +// using the (type, value, matchVal) signature. +function matchValue(type, value, matchVal, matchObj) { + if (value === null || value === undefined) { + if (type === 'isset') { + return false; + } + return false; + } + + const strValue = typeof value === 'string' ? value : String(value); + + switch (type) { + case 'equals': + return strValue == matchVal; + + case 'equals_strict': + return strValue === matchVal; + + case 'contains': + // engine-php exposes `stripos` as an alias of `contains`. + case 'stripos': { + const lower = strValue.toLowerCase(); + const target = String(matchVal).toLowerCase(); + return lower.includes(target); + } + + case 'not_contains': { + const lower = strValue.toLowerCase(); + const target = String(matchVal).toLowerCase(); + return !lower.includes(target); + } + + case 'regex': { + const regex = safeRegExp(matchVal); + if (!regex) { + return false; + } + return regex.test(strValue); + } + + case 'more_than': + return Number(strValue) > Number(matchVal); + + case 'less_than': + return Number(strValue) < Number(matchVal); + + case 'ctype_digit': + return ctypeResult(strValue, /^\d+$/.test(strValue), matchVal); + + case 'ctype_alnum': + return ctypeResult(strValue, /^[\w$\u0080-\uFFFF]+$/.test(strValue), matchVal); + + case 'is_numeric': + return ctypeResult(strValue, !isNaN(strValue) && strValue.trim() !== '', matchVal); + + case 'isset': + return true; + + case 'in_array': { + const arr = Array.isArray(matchVal) ? matchVal : [matchVal]; + return arr.includes(strValue); + } + + case 'not_in_array': { + const arr = Array.isArray(matchVal) ? matchVal : [matchVal]; + return !arr.includes(strValue); + } + + case 'array_in_array': { + if (!Array.isArray(value) || !Array.isArray(matchVal)) { + return false; + } + return value.some(v => matchVal.includes(v)); + } + + case 'hostname': { + try { + const url = new URL(strValue); + return url.hostname === matchVal; + } catch { + return false; + } + } + + case 'quotes': + // engine-php exposes `inline_js_xss` as an alias of `quotes`. + case 'inline_js_xss': + return /['"]/.test(strValue); + + case 'inline_xss': + // Attribute-breakout heuristic: a quote AND a `>` or `=` (matches engine-php). + return /['"]/.test(strValue) && /[>=]/.test(strValue); + + case 'ctype_special': { + // engine-php strips space, `_`, `-`, `,` then applies the alnum/unicode check, + // and compares the result to `value` (usually false = "flag when NOT clean"). + const stripped = strValue.replace(/[ _\-,]/g, ''); + return ctypeResult(strValue, /^[\w$€-￿]*$/.test(stripped), matchVal); + } + + case 'array_key_value': + // Navigate `match.key` (dot-path, or array of paths) inside the decoded value and + // run the nested `match.match` against it — e.g. a json_decode'd body where a + // specific key must hold a specific value. + return arrayKeyValue(value, matchObj); + + // file_contains (uploaded-file content scanning) is NOT WordPress-specific, but is + // not yet implemented in the JS engine (needs multipart body access). Kept as an + // explicit, documented no-match rather than silently hitting `default`. + case 'file_contains': + return false; + + // WordPress-only match types (current_user_cannot, general_xss, getShortcodeAtts, + // getBlockAtts) have been removed: they depend on WP capabilities, wp_kses_post, and + // the shortcode/block parsers, and have no meaning in a JS app. They now fall through + // here and are reported once instead of pretending to be a no-match. + default: + warnUnsupportedMatchType(type); + return false; + } +} + +export class RuleEngine { + #rules; + #whitelists; + #whitelistKeys; + #onError; + #reportedErrors = new Set(); + + constructor({ firewall = [], whitelists = [], whitelist_keys = {}, onError } = {}) { + this.#rules = firewall; + this.#whitelists = whitelists; + this.#whitelistKeys = whitelist_keys; + this.#onError = onError; + } + + // A mitigation engine must never take down the app it protects: any error while + // evaluating a rule is reported and the request is allowed through (fail open). A + // malformed rule is skipped without aborting the rest of the ruleset. + #reportError(err) { + if (this.#onError) { + this.#onError(err); + return; + } + // Default: log once per distinct message so a persistently-broken rule doesn't + // spam per request (never silently swallow — hosts can pass `onError` for structured logging). + const key = err && err.message ? err.message : String(err); + if (this.#reportedErrors.has(key)) { + return; + } + this.#reportedErrors.add(key); + console.error('[patchstack] rule evaluation error (rule skipped, request allowed):', err); + } + + evaluate(req) { + let normalizedReq; + let resolver; + try { + normalizedReq = { ...req, ...normalizeRequest(req) }; + resolver = new RequestResolver(normalizedReq); + } catch (err) { + this.#reportError(err); + return { blocked: false, rule: null, message: null }; // fail open + } + + for (const rule of this.#rules) { + try { + const conditions = rule.rule_v2; + + if (!Array.isArray(conditions) || conditions.length === 0) { + continue; + } + + if (this.#evaluateRule(conditions, resolver)) { + if (this.#isWhitelisted(normalizedReq, rule)) { + continue; + } + + return { + blocked: true, + rule, + message: rule.message ?? `Blocked by Patchstack WAF rule: ${rule.title ?? rule.id}` + }; + } + } catch (err) { + // Skip this rule and keep evaluating the rest — one bad rule never blocks a + // request nor shadows the rules after it. + this.#reportError(err); + } + } + + return { blocked: false, rule: null, message: null }; + } + + #evaluateRule(conditions, resolver) { + const inclusiveConditions = conditions.filter(c => c.inclusive); + const nonInclusiveConditions = conditions.filter(c => !c.inclusive); + + if (inclusiveConditions.length > 0) { + let inclusiveHits = 0; + + for (const condition of inclusiveConditions) { + if (this.#evaluateCondition(condition, resolver)) { + inclusiveHits++; + } + } + + if (inclusiveHits === inclusiveConditions.length) { + return true; + } + } + + for (const condition of nonInclusiveConditions) { + if (this.#evaluateCondition(condition, resolver)) { + return true; + } + } + + return false; + } + + #evaluateCondition(condition, resolver) { + const { parameter, match, mutations } = condition; + + if (parameter === 'rules' && Array.isArray(condition.rules)) { + return this.#evaluateRule(condition.rules, resolver); + } + + const values = resolver.resolve(parameter); + + if (values.length === 0) { + return false; + } + + for (let value of values) { + if (mutations) { + value = resolver.applyMutations(mutations, value); + } + + // array_key_value inspects the object as a whole (it navigates a key path), + // so it must not go through the per-value object iteration below. + if (match.type === 'array_key_value') { + if (matchValue(match.type, value, match.value, match)) { + return true; + } + continue; + } + + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + for (const v of Object.values(value)) { + if (matchValue(match.type, v, match.value, match)) { + return true; + } + } + continue; + } + + if (matchValue(match.type, value, match.value, match)) { + return true; + } + } + + return false; + } + + #isWhitelisted(req, rule) { + if (!this.#whitelists || this.#whitelists.length === 0) { + return false; + } + + for (const whitelist of this.#whitelists) { + if (!Array.isArray(whitelist.rule_v2)) { + continue; + } + + if (whitelist.rule_id && whitelist.rule_id !== rule.id) { + continue; + } + + const resolver = new RequestResolver(req); + if (this.#evaluateRule(whitelist.rule_v2, resolver)) { + return true; + } + } + + return false; + } +} + +export const _testExports = { matchValue, safeRegExp }; diff --git a/src/protect/engine/fetch.js b/src/protect/engine/fetch.js new file mode 100644 index 0000000..3a3321a --- /dev/null +++ b/src/protect/engine/fetch.js @@ -0,0 +1,157 @@ +// Web Fetch adapter — runs the node-waf engine on any runtime where a request is a +// WHATWG `Request` and a handler returns a `Response`: Cloudflare Workers, Bun, Deno, +// Hono, Next.js edge, and TanStack Start's `server.ts`. This is the surface AI-builder +// apps actually deploy to, and where require-based instrumentation can't reach. +// +// The engine already consumes a runtime-neutral request shape; this adapter builds that +// shape from a `Request`, so no engine changes are needed beyond keeping the hot path +// free of Node-only APIs. +import { RuleEngine } from './engine.js'; + +// Build the engine's request shape from a WHATWG Request. The body is read from a +// CLONE so the downstream handler still receives an intact request. +export async function fromFetchRequest(request) { + const url = new URL(request.url); + const method = (request.method || 'GET').toUpperCase(); + + const headers = {}; + request.headers.forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + + const query = {}; + for (const [key, value] of url.searchParams) { + if (key in query) { + query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value]; + } else { + query[key] = value; + } + } + + let rawBody = ''; + if (method !== 'GET' && method !== 'HEAD') { + try { + rawBody = await request.clone().text(); + } catch { + rawBody = ''; + } + } + + const contentType = headers['content-type'] || ''; + let body = {}; + if (rawBody) { + if (contentType.includes('application/json')) { + try { + body = JSON.parse(rawBody); + } catch { + body = {}; + } + } else if (contentType.includes('application/x-www-form-urlencoded')) { + body = {}; + for (const [k, v] of new URLSearchParams(rawBody)) { + body[k] = k in body ? [].concat(body[k], v) : v; + } + } + } + + const uri = url.pathname + url.search; + const forwarded = + headers['cf-connecting-ip'] || headers['x-forwarded-for'] || headers['x-real-ip'] || ''; + + return { + method, + url: uri, + originalUrl: uri, + query, + body, + headers, + ip: forwarded.split(',')[0].trim(), + cookies: parseCookies(headers.cookie), + // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify + // drops, so prototype-pollution rules on `raw` are robust. + _rawBody: rawBody + }; +} + +function parseCookies(header) { + const cookies = {}; + if (!header) { + return cookies; + } + for (const pair of header.split(';')) { + const idx = pair.indexOf('='); + if (idx === -1) { + continue; + } + cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim(); + } + return cookies; +} + +function defaultBlockResponse(result) { + return new Response( + JSON.stringify({ + error: 'Blocked by Patchstack WAF', + message: result.message, + timestamp: new Date().toISOString() + }), + { status: 403, headers: { 'content-type': 'application/json' } } + ); +} + +/** + * Returns a guard `(request) => Promise`. A `Response` means blocked; + * `null` means allowed — the caller should proceed to its handler. Accepts either a + * rules bundle (`{ firewall, whitelists, whitelist_keys }`) or a `RuleEngine` instance. + * Fails open: a rule that throws never blocks the request. + */ +export function createFetchMiddleware(rulesData, options = {}) { + const engine = + rulesData && typeof rulesData.evaluate === 'function' + ? rulesData + : new RuleEngine(rulesData); + + const guard = async (request) => { + const req = await fromFetchRequest(request); + + let result; + try { + result = engine.evaluate(req); + } catch (err) { + if (options.onError) { + options.onError(err); + } + return null; // fail open + } + + if (result.blocked) { + if (options.onBlock) { + options.onBlock({ + rule: result.rule, + message: result.message, + request: { method: req.method, url: req.url, ip: req.ip } + }); + } + return (options.response || defaultBlockResponse)(result); + } + + return null; + }; + + guard.engine = engine; + return guard; +} + +/** + * Wrap a fetch handler so every request is screened first. One-line hook: + * export default { fetch: wrapFetchHandler(app.fetch, rulesData) } + */ +export function wrapFetchHandler(handler, rulesData, options = {}) { + const guard = createFetchMiddleware(rulesData, options); + const wrapped = async (request, ...rest) => { + const blocked = await guard(request); + return blocked ?? handler(request, ...rest); + }; + wrapped.engine = guard.engine; + return wrapped; +} diff --git a/src/protect/engine/index.d.ts b/src/protect/engine/index.d.ts new file mode 100644 index 0000000..cef6924 --- /dev/null +++ b/src/protect/engine/index.d.ts @@ -0,0 +1,127 @@ +// Client +export interface RuleClientOptions { + token?: string; + baseUrl?: string; + cacheTtl?: number; +} + +export interface RuleCondition { + parameter: string; + match: { type: string; value: any }; + inclusive?: boolean; + mutations?: string[]; + rules?: RuleCondition[]; +} + +export interface FirewallRule { + id: number; + title: string; + rule_v2: RuleCondition[]; + message?: string; +} + +export interface Whitelist { + rule_id?: number; + rule_v2: RuleCondition[]; +} + +export interface RulesResult { + success: boolean; + error?: string; + firewall: FirewallRule[]; + whitelists: Whitelist[]; + whitelist_keys: Record; +} + +export declare class PatchstackRuleClient { + constructor(options?: RuleClientOptions); + getRules(): Promise; + clearCache(): void; +} + +// Engine +export interface EvaluateResult { + blocked: boolean; + rule: FirewallRule | null; + message: string | null; +} + +export declare class RuleEngine { + constructor(rulesData?: { firewall?: FirewallRule[]; whitelists?: Whitelist[]; whitelist_keys?: Record }); + evaluate(req: any): EvaluateResult; +} + +// Request +export declare class RequestResolver { + constructor(req: any); + resolve(parameter: string): any[]; + applyMutations(mutations: string[], value: any): any; +} + +// Middleware +export interface ProtectOptions { + token?: string; + baseUrl?: string; + cacheTtl?: number; + logging?: boolean; + onBlock?: (event: any) => void; + onScan?: (rulesData: RulesResult) => void; + onError?: (error: Error) => void; +} + +export interface WafStats { + total: number; + blocked: number; + allowed: number; + avgDuration: number; +} + +export interface WafEvent { + timestamp: string; + method: string; + url: string; + status: number; + duration: number; + blocked: boolean; +} + +export interface WafLogger { + middleware: (req: any, res: any, next: any) => void; + getStats: () => WafStats; + getEvents: () => WafEvent[]; +} + +export interface ProtectMiddleware { + (req: any, res: any, next: any): void; + getStats?: () => WafStats; + getEvents?: () => WafEvent[]; + rules?: RulesResult; + engine?: RuleEngine; +} + +export declare function createMiddleware(rulesData: { firewall?: FirewallRule[]; whitelists?: Whitelist[] }, options?: { onBlock?: (event: any) => void }): (req: any, res: any, next: any) => void; +export declare function createLogger(): WafLogger; +export declare function protect(options?: ProtectOptions): Promise; +export declare function protectSync(options?: ProtectOptions): (req: any, res: any, next: any) => void; + +// Normalizer +export interface NormalizeOptions { + urlDecode?: boolean; + htmlDecode?: boolean; + sqlComments?: boolean; + nullBytes?: boolean; + whitespace?: boolean; +} + +export declare function normalize(value: string, options?: NormalizeOptions): string; +export declare function normalizeRequest(req: any, options?: NormalizeOptions): { + query: Record; + body: Record; + headers: Record; + url: string; + originalUrl: string; + _rawBody: string; +}; +export declare function urlDecode(value: string): string; +export declare function htmlEntityDecode(value: string): string; +export declare function removeSqlComments(value: string): string; diff --git a/src/protect/engine/index.js b/src/protect/engine/index.js new file mode 100644 index 0000000..8b185e4 --- /dev/null +++ b/src/protect/engine/index.js @@ -0,0 +1,5 @@ +export { PatchstackRuleClient } from './client.js'; +export { RuleEngine } from './engine.js'; +export { RequestResolver } from './request.js'; +export { createMiddleware, createLogger, protect, protectSync } from './middleware.js'; +export { normalize, normalizeRequest, urlDecode, htmlEntityDecode, removeSqlComments } from './normalizer.js'; diff --git a/src/protect/engine/middleware.js b/src/protect/engine/middleware.js new file mode 100644 index 0000000..343a146 --- /dev/null +++ b/src/protect/engine/middleware.js @@ -0,0 +1,191 @@ +import { PatchstackRuleClient } from './client.js'; +import { RuleEngine } from './engine.js'; + +export function createMiddleware(rulesData, options = {}) { + const engine = new RuleEngine(rulesData); + + const middleware = (req, res, next) => { + const result = engine.evaluate(req); + + if (result.blocked) { + if (options.onBlock) { + options.onBlock({ + rule: result.rule, + message: result.message, + request: { + method: req.method, + url: req.url, + ip: req.ip ?? req.socket?.remoteAddress + } + }); + } + + return res.status(403).json({ + error: 'Blocked by Patchstack WAF', + message: result.message, + timestamp: new Date().toISOString() + }); + } + + next(); + }; + + middleware.engine = engine; + + return middleware; +} + +export function createLogger() { + const events = []; + const MAX_EVENTS = 100; + let stats = { total: 0, blocked: 0, allowed: 0, totalDuration: 0 }; + + const middleware = (req, res, next) => { + const start = Date.now(); + + const originalEnd = res.end.bind(res); + res.end = function (...args) { + const duration = Date.now() - start; + const blocked = res.statusCode === 403; + + stats.total++; + stats.totalDuration += duration; + + if (blocked) { + stats.blocked++; + } else { + stats.allowed++; + } + + events.push({ + timestamp: new Date().toISOString(), + method: req.method, + url: req.url, + status: res.statusCode, + duration, + blocked + }); + + if (events.length > MAX_EVENTS) { + events.shift(); + } + + return originalEnd(...args); + }; + + next(); + }; + + return { + middleware, + getStats: () => ({ + total: stats.total, + blocked: stats.blocked, + allowed: stats.allowed, + avgDuration: stats.total > 0 ? Math.round(stats.totalDuration / stats.total) : 0 + }), + getEvents: () => [...events] + }; +} + +export async function protect(options = {}) { + const token = options.token ?? process.env.PATCHSTACK_WAF_TOKEN; + + if (!token) { + console.warn('[patchstack] No WAF token provided. WAF protection disabled.'); + return passThrough(); + } + + try { + const client = new PatchstackRuleClient({ + token, + baseUrl: options.baseUrl, + cacheTtl: options.cacheTtl + }); + + const rulesData = await client.getRules(); + + if (!rulesData.success) { + console.warn(`[patchstack] Failed to fetch WAF rules: ${rulesData.error}. WAF protection disabled.`); + return passThrough(); + } + + if (options.onScan) { + options.onScan(rulesData); + } + + const wafMiddleware = createMiddleware(rulesData, options); + + if (options.logging !== false) { + const logger = createLogger(); + + const combined = (req, res, next) => { + logger.middleware(req, res, () => { + wafMiddleware(req, res, next); + }); + }; + + combined.getStats = logger.getStats; + combined.getEvents = logger.getEvents; + combined.rules = rulesData; + combined.engine = wafMiddleware.engine; + + return combined; + } + + wafMiddleware.rules = rulesData; + + return wafMiddleware; + } catch (err) { + console.warn(`[patchstack] WAF initialization error: ${err.message}. WAF protection disabled.`); + return passThrough(); + } +} + +export function protectSync(options = {}) { + let initialized = false; + let initError = null; + let middleware = null; + let initPromise = null; + + const lazyMiddleware = (req, res, next) => { + if (initError) { + return next(); + } + + if (initialized && middleware) { + return middleware(req, res, next); + } + + if (!initPromise) { + initPromise = protect(options) + .then(m => { + middleware = m; + initialized = true; + }) + .catch(err => { + initError = err; + console.warn(`[patchstack] WAF lazy init failed: ${err.message}. Passing through.`); + + if (options.onError) { + options.onError(err); + } + }); + } + + initPromise + .then(() => { + if (initError) { + return next(); + } + return middleware(req, res, next); + }) + .catch(() => next()); + }; + + return lazyMiddleware; +} + +function passThrough() { + return (_req, _res, next) => next(); +} diff --git a/src/protect/engine/node.js b/src/protect/engine/node.js new file mode 100644 index 0000000..97046ec --- /dev/null +++ b/src/protect/engine/node.js @@ -0,0 +1,151 @@ +// Raw Node.js / Connect adapter — for traditional Node servers where the request is a +// `http.IncomingMessage` and the body is NOT already parsed (plain `http`, Connect, +// Express without a body-parser, Fastify via its Node req). It buffers the body once, +// builds the engine's request shape, and blocks or calls `next()`. +// +// This complements the Express `createMiddleware` (which assumes `req.body`/`req.query` +// are already populated) and the Web-Fetch adapter (Workers/edge). Mount it FIRST, before +// any body-parser — it consumes the stream and exposes the parsed body as `req.body`. +import { RuleEngine } from './engine.js'; + +// Build the engine's request shape from a Node IncomingMessage + its raw body text. +export function fromNodeRequest(req, rawBody = '') { + const method = (req.method || 'GET').toUpperCase(); + + const headers = {}; + for (const [key, value] of Object.entries(req.headers || {})) { + headers[key.toLowerCase()] = Array.isArray(value) ? value.join(', ') : value; + } + + const host = headers.host || 'localhost'; + const url = new URL(req.url || '/', `http://${host}`); + + const query = {}; + for (const [key, value] of url.searchParams) { + if (key in query) { + query[key] = Array.isArray(query[key]) ? [...query[key], value] : [query[key], value]; + } else { + query[key] = value; + } + } + + const contentType = headers['content-type'] || ''; + let body = {}; + if (rawBody) { + if (contentType.includes('application/json')) { + try { + body = JSON.parse(rawBody); + } catch { + body = {}; + } + } else if (contentType.includes('application/x-www-form-urlencoded')) { + body = {}; + for (const [k, v] of new URLSearchParams(rawBody)) { + body[k] = k in body ? [].concat(body[k], v) : v; + } + } + } + + const uri = url.pathname + url.search; + const forwarded = + headers['cf-connecting-ip'] || headers['x-forwarded-for'] || headers['x-real-ip'] || ''; + + return { + method, + url: uri, + originalUrl: uri, + query, + body, + headers, + ip: forwarded.split(',')[0].trim() || (req.socket && req.socket.remoteAddress) || '', + cookies: parseCookies(headers.cookie), + // Verbatim body text: preserves literal keys (e.g. `__proto__`) that JSON.stringify drops. + _rawBody: rawBody + }; +} + +function parseCookies(header) { + const cookies = {}; + if (!header) { + return cookies; + } + for (const pair of header.split(';')) { + const idx = pair.indexOf('='); + if (idx === -1) { + continue; + } + cookies[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim(); + } + return cookies; +} + +function defaultBlock(res, result) { + res.statusCode = 403; + res.setHeader('content-type', 'application/json'); + res.end( + JSON.stringify({ + error: 'Blocked by Patchstack WAF', + message: result.message, + timestamp: new Date().toISOString() + }) + ); +} + +/** + * Connect/Express-style middleware `(req, res, next)` that buffers the body itself. + * Accepts a `RuleEngine` instance or a `{ firewall, whitelists, whitelist_keys }` bundle. + * Fails open: an engine error (or oversized body) never blocks the request. + * Options: `{ maxBodyBytes = 1MiB, onBlock, onError, response }`. + */ +export function createNodeMiddleware(rulesData, options = {}) { + const engine = + rulesData && typeof rulesData.evaluate === 'function' ? rulesData : new RuleEngine(rulesData); + const maxBytes = options.maxBodyBytes ?? 1024 * 1024; + + return function guard(req, res, next) { + const chunks = []; + let size = 0; + let overflow = false; + + req.on('data', (chunk) => { + size += chunk.length; + if (size > maxBytes) { + overflow = true; + return; + } + chunks.push(chunk); + }); + + req.on('error', (err) => next(err)); + + req.on('end', () => { + const rawBody = overflow ? '' : Buffer.concat(chunks).toString('utf8'); + const shaped = fromNodeRequest(req, rawBody); + + let result; + try { + result = engine.evaluate(shaped); + } catch (err) { + if (options.onError) { + options.onError(err); + } + return next(); // fail open + } + + if (result.blocked) { + if (options.onBlock) { + options.onBlock({ + rule: result.rule, + message: result.message, + request: { method: shaped.method, url: shaped.url, ip: shaped.ip } + }); + } + return (options.response || defaultBlock)(res, result); + } + + // Expose the parsed body downstream so a body-parser isn't also required. + req.body = shaped.body; + next(); + }); + }; +} diff --git a/src/protect/engine/normalizer.js b/src/protect/engine/normalizer.js new file mode 100644 index 0000000..f3f1b02 --- /dev/null +++ b/src/protect/engine/normalizer.js @@ -0,0 +1,273 @@ +/** + * Request Normalization Pipeline + * + * Normalizes request data to prevent encoding-based bypass attacks. + * This module handles URL encoding, HTML entities, SQL comments, and other + * obfuscation techniques that attackers use to evade pattern matching. + */ + +const HTML_ENTITIES = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + ''': "'", + ' ': ' ', + ''': "'", + '"': '"', + '<': '<', + '>': '>', + '&': '&', + '"': '"', + '<': '<', + '>': '>', + '&': '&' +}; + +const MAX_DECODE_ITERATIONS = 5; + +export function normalize(value, options = {}) { + if (typeof value !== 'string') { + return value; + } + + const opts = { + urlDecode: true, + htmlDecode: true, + sqlComments: true, + nullBytes: true, + whitespace: true, + ...options + }; + + let result = value; + + if (opts.nullBytes) { + result = removeNullBytes(result); + } + + if (opts.urlDecode) { + result = urlDecode(result); + } + + if (opts.htmlDecode) { + result = htmlEntityDecode(result); + } + + if (opts.sqlComments) { + result = removeSqlComments(result); + } + + if (opts.whitespace) { + result = normalizeWhitespace(result); + } + + return result; +} + +export function urlDecode(value) { + if (typeof value !== 'string') { + return value; + } + + let result = value; + let previous = ''; + let iterations = 0; + + while (result !== previous && iterations < MAX_DECODE_ITERATIONS) { + previous = result; + iterations++; + + try { + result = decodeURIComponent(result); + } catch { + result = safeUrlDecode(result); + break; + } + } + + return result; +} + +function safeUrlDecode(value) { + return value.replace(/%([0-9A-Fa-f]{2})/g, (match, hex) => { + try { + return String.fromCharCode(parseInt(hex, 16)); + } catch { + return match; + } + }); +} + +export function htmlEntityDecode(value) { + if (typeof value !== 'string') { + return value; + } + + let result = value; + + for (const [entity, char] of Object.entries(HTML_ENTITIES)) { + result = result.split(entity).join(char); + } + + result = result.replace(/&#(\d+);/g, (match, code) => { + const num = parseInt(code, 10); + return num > 0 && num < 65536 ? String.fromCharCode(num) : match; + }); + + result = result.replace(/&#x([0-9A-Fa-f]+);/g, (match, hex) => { + const num = parseInt(hex, 16); + return num > 0 && num < 65536 ? String.fromCharCode(num) : match; + }); + + return result; +} + +export function removeSqlComments(value) { + if (typeof value !== 'string') { + return value; + } + + let result = value; + + result = result.replace(/\/\*[\s\S]*?\*\//g, ' '); + result = result.replace(/\/\*![\s\S]*?\*\//g, ' '); + result = result.replace(/--[^\r\n]*/g, ''); + result = result.replace(/#[^\r\n]*/g, ''); + + return result; +} + +export function removeNullBytes(value) { + if (typeof value !== 'string') { + return value; + } + + let result = value.replace(/\x00/g, ''); + + result = result.replace(/[\x01-\x08\x0B\x0C\x0E-\x1F]/g, ''); + + return result; +} + +export function normalizeWhitespace(value) { + if (typeof value !== 'string') { + return value; + } + + let result = value.replace(/[\t\r\n\f\v]+/g, ' '); + + result = result.replace(/ {2,}/g, ' '); + + return result; +} + +function serializeForRawDetection(body, visited = new Set(), isRoot = true) { + if (body === null || body === undefined) { + return isRoot ? '' : 'null'; + } + + if (typeof body === 'string') { + return isRoot ? body : JSON.stringify(body); + } + + if (typeof body !== 'object') { + return String(body); + } + + if (visited.has(body)) { + return '[Circular]'; + } + visited.add(body); + + if (Array.isArray(body)) { + const items = body.map(item => serializeForRawDetection(item, visited, false)); + return '[' + items.join(',') + ']'; + } + + // Object.getOwnPropertyNames includes non-enumerable own properties, so a __proto__ key + // set via Object.defineProperty (as modern JSON.parse may do) is included in the output. + const keys = Object.getOwnPropertyNames(body); + const parts = keys.map(key => { + const val = serializeForRawDetection(body[key], visited, false); + return JSON.stringify(key) + ':' + val; + }); + + return '{' + parts.join(',') + '}'; +} + +export function normalizeRequest(req, options = {}) { + // Prefer a caller-provided verbatim body string (set by the fetch/node adapters): + // it preserves literal keys like `__proto__` that JSON.stringify drops, which is + // what makes prototype-pollution rules on `raw` robust. Fall back to a + // reconstruction from the parsed body (the Express path, which has no raw text). + const rawBody = typeof req._rawBody === 'string' + ? req._rawBody + : serializeForRawDetection(req.body ?? null); + + return { + query: normalizeObject(req.query || {}, options), + body: normalizeObject(req.body || {}, options), + headers: normalizeObject(req.headers || {}, options), + url: normalize(req.url || '', options), + originalUrl: normalize(req.originalUrl || req.url || '', options), + _rawBody: rawBody + }; +} + +export function normalizeObject(value, options = {}) { + if (typeof value === 'string') { + return normalize(value, options); + } + + if (Array.isArray(value)) { + return value.map(item => normalizeObject(item, options)); + } + + if (typeof value === 'object' && value !== null) { + const result = {}; + + for (const [key, val] of Object.entries(value)) { + result[key] = normalizeObject(val, options); + } + + return result; + } + + return value; +} + +export function createMatchVariants(value) { + if (typeof value !== 'string') { + return [value]; + } + + const variants = new Set(); + + variants.add(value); + + const urlDecoded = urlDecode(value); + variants.add(urlDecoded); + + const htmlDecoded = htmlEntityDecode(value); + variants.add(htmlDecoded); + + const bothDecoded = htmlEntityDecode(urlDecoded); + variants.add(bothDecoded); + + const normalized = normalize(value); + variants.add(normalized); + + variants.add(value.toLowerCase()); + variants.add(normalized.toLowerCase()); + + return Array.from(variants); +} + +export const _testExports = { + safeUrlDecode, + serializeForRawDetection, + HTML_ENTITIES, + MAX_DECODE_ITERATIONS +}; diff --git a/src/protect/engine/request.js b/src/protect/engine/request.js new file mode 100644 index 0000000..37f481d --- /dev/null +++ b/src/protect/engine/request.js @@ -0,0 +1,361 @@ +// WinterCG-safe base64 decode: use Buffer on Node, fall back to atob/TextDecoder on +// edge runtimes (Cloudflare Workers, Deno, Bun) where Buffer may be absent. Keeps the +// engine hot path free of Node-only APIs (per the ADR engine-language decision). +function base64DecodeUtf8(value) { + const str = String(value); + if (typeof Buffer !== 'undefined') { + return Buffer.from(str, 'base64').toString('utf-8'); + } + const binary = atob(str); + const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +export class RequestResolver { + #req; + #cookies; + + constructor(req) { + this.#req = req; + this.#cookies = null; + } + + resolve(parameter) { + if (!parameter || parameter === 'false') { + return [null]; + } + + if (parameter === 'rules') { + return [null]; + } + + if (parameter === 'raw') { + return this.#resolveRaw(); + } + + if (parameter === 'all') { + return this.#resolveAll(); + } + + const dotIndex = parameter.indexOf('.'); + if (dotIndex === -1) { + return []; + } + + const source = parameter.substring(0, dotIndex); + const key = parameter.substring(dotIndex + 1); + + switch (source) { + case 'get': + return this.#resolveGet(key); + case 'post': + return this.#resolvePost(key); + case 'request': + return this.#resolveRequest(key); + case 'cookie': + return this.#resolveCookie(key); + case 'server': + return this.#resolveServer(key); + case 'files': + return this.#resolveFiles(key); + default: + return []; + } + } + + applyMutations(mutations, value) { + if (!mutations || !Array.isArray(mutations)) { + return value; + } + + let result = value; + + for (const mutation of mutations) { + result = this.#applyMutation(mutation, result); + } + + return result; + } + + #applyMutation(mutation, value) { + if (value === null || value === undefined) { + return value; + } + + switch (mutation) { + case 'base64_decode': + try { + return base64DecodeUtf8(value); + } catch { + return value; + } + + case 'json_decode': + try { + return JSON.parse(String(value)); + } catch { + return value; + } + + case 'json_encode': + try { + return JSON.stringify(value); + } catch { + return value; + } + + case 'urldecode': + try { + return decodeURIComponent(String(value)); + } catch { + return value; + } + + case 'intval': + return parseInt(String(value), 10) || 0; + + case 'getArrayValues': + if (typeof value === 'object' && value !== null) { + return Object.values(value); + } + return value; + + default: + return value; + } + } + + #resolveGet(key) { + const query = this.#req.query ?? {}; + + if (key.endsWith('*')) { + return this.#resolveWildcard(query, key); + } + + const value = this.#getNestedValue(query, key); + return value !== undefined ? [value] : []; + } + + #resolvePost(key) { + const body = this.#req.body ?? {}; + + if (key.endsWith('*')) { + return this.#resolveWildcard(body, key); + } + + const value = this.#getNestedValue(body, key); + return value !== undefined ? [value] : []; + } + + #resolveRequest(key) { + const query = this.#req.query ?? {}; + const body = this.#req.body ?? {}; + const cookies = this.#parseCookies(); + + if (key.endsWith('*')) { + return [ + ...this.#resolveWildcard(query, key), + ...this.#resolveWildcard(body, key), + ...this.#resolveWildcard(cookies, key) + ]; + } + + const value = this.#getNestedValue(query, key) + ?? this.#getNestedValue(body, key) + ?? cookies[key]; + + return value !== undefined ? [value] : []; + } + + #resolveCookie(key) { + const cookies = this.#parseCookies(); + + if (key.endsWith('*')) { + return this.#resolveWildcard(cookies, key); + } + + const value = cookies[key]; + return value !== undefined ? [value] : []; + } + + #resolveServer(key) { + const req = this.#req; + + switch (key) { + case 'REQUEST_URI': + return [req.originalUrl ?? req.url ?? '/']; + case 'REQUEST_METHOD': + return [req.method ?? 'GET']; + case 'HTTP_USER_AGENT': + return req.headers?.['user-agent'] ? [req.headers['user-agent']] : []; + case 'HTTP_REFERER': + return req.headers?.referer ? [req.headers.referer] : []; + case 'HTTP_HOST': + return req.headers?.host ? [req.headers.host] : []; + case 'REMOTE_ADDR': + case 'ip': + return [req.ip ?? req.socket?.remoteAddress ?? '']; + case 'CONTENT_TYPE': + return req.headers?.['content-type'] ? [req.headers['content-type']] : []; + case 'CONTENT_LENGTH': + return req.headers?.['content-length'] ? [req.headers['content-length']] : []; + default: { + if (key.startsWith('HTTP_')) { + const headerName = key.substring(5).toLowerCase().replace(/_/g, '-'); + return req.headers?.[headerName] ? [req.headers[headerName]] : []; + } + return []; + } + } + } + + #resolveFiles(key) { + const files = this.#req.files; + if (!files) { + return []; + } + + if (key.endsWith('*')) { + return this.#resolveWildcard(files, key); + } + + const value = files[key]; + return value !== undefined ? [value] : []; + } + + #resolveRaw() { + // Use pre-captured raw body if available (set by normalizeRequest). + // For string bodies, the original text is preserved verbatim. + // For pre-parsed objects, serializeForRawDetection uses Object.getOwnPropertyNames() + // to include __proto__ own-property keys that JSON.stringify() would silently drop. + if (typeof this.#req._rawBody === 'string') { + return this.#req._rawBody ? [this.#req._rawBody] : []; + } + + const body = this.#req.body; + + if (body === undefined || body === null) { + return []; + } + + if (typeof body === 'string') { + return [body]; + } + + try { + return [JSON.stringify(body)]; + } catch { + return [String(body)]; + } + } + + #resolveAll() { + const parts = []; + + const uri = this.#req.originalUrl ?? this.#req.url ?? '/'; + parts.push(uri); + + const queryString = uri.includes('?') ? uri.split('?')[1] : ''; + if (queryString) { + parts.push(queryString); + } + + const body = this.#req.body; + if (body) { + parts.push(typeof body === 'string' ? body : JSON.stringify(body)); + } + + const headers = this.#req.headers ?? {}; + const excludedHeaders = new Set([ + 'host', 'connection', 'cache-control', 'accept', 'accept-encoding', + 'accept-language', 'priority', 'sec-ch-ua', 'sec-ch-ua-mobile', + 'sec-ch-ua-platform', 'sec-fetch-dest', 'sec-fetch-mode', + 'sec-fetch-site', 'sec-fetch-user', 'upgrade-insecure-requests' + ]); + + for (const [name, value] of Object.entries(headers)) { + if (!excludedHeaders.has(name)) { + parts.push(`${name}: ${value}`); + } + } + + const cookies = this.#parseCookies(); + const cookieStr = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '); + if (cookieStr) { + parts.push(cookieStr); + } + + return [parts.join(' ')]; + } + + #resolveWildcard(obj, pattern) { + if (typeof obj !== 'object' || obj === null) { + return []; + } + + const prefix = pattern.slice(0, -1); + const values = []; + + for (const [key, value] of Object.entries(obj)) { + if (key.startsWith(prefix)) { + values.push(value); + } + } + + return values; + } + + #getNestedValue(obj, key) { + if (typeof obj !== 'object' || obj === null) { + return undefined; + } + + if (key in obj) { + return obj[key]; + } + + const parts = key.split('.'); + let current = obj; + + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') { + return undefined; + } + current = current[part]; + } + + return current; + } + + #parseCookies() { + if (this.#cookies !== null) { + return this.#cookies; + } + + if (this.#req.cookies) { + this.#cookies = this.#req.cookies; + return this.#cookies; + } + + const header = this.#req.headers?.cookie; + if (!header) { + this.#cookies = {}; + return this.#cookies; + } + + const cookies = {}; + + for (const pair of header.split(';')) { + const eqIndex = pair.indexOf('='); + if (eqIndex === -1) { + continue; + } + const name = pair.substring(0, eqIndex).trim(); + const value = pair.substring(eqIndex + 1).trim(); + cookies[name] = value; + } + + this.#cookies = cookies; + return this.#cookies; + } +} diff --git a/src/protect/install.ts b/src/protect/install.ts index 67e42aa..6b4755b 100644 --- a/src/protect/install.ts +++ b/src/protect/install.ts @@ -1,25 +1,29 @@ -// `patchstack-connect protect` — installs the runtime guard into a server-side JS app. +// `patchstack-connect protect` — installs the runtime guard into a TanStack Start + Supabase app. // -// This is the paid "virtual patching" layer. "Add Patchstack" already installs the connector; -// this wires the guard so exploit requests against known-vulnerable packages are blocked, with -// zero changes to the user's own code. Idempotent: safe to run on every build. +// "Add Patchstack" already installs the connector; this wires the always-on guard so exploit +// requests against known-vulnerable packages are blocked, with zero changes to the user's own +// code. Idempotent (safe to re-run). // -// It only edits Patchstack-owned / auto-generated infra: the guard folder -// (src/integrations/patchstack/), the generated Supabase client, and the framework server -// entry. It never touches the user's routes or components. Best-effort — it must never fail a -// build, so callers treat a thrown error as "skip". +// The engine ships inside @patchstack/connect (exported as @patchstack/connect/protect), so the +// scaffolded guard just imports it — no extra dependency, no local manifest. Rules come from the +// Patchstack API at runtime (cached), with a bundled fallback until a token is configured. import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; // Guard templates ship next to the built CLI (dist/protect/templates). -const TEMPLATES = fileURLToPath(new URL('./protect/templates/', import.meta.url)); +const TEMPLATES = join(dirname(fileURLToPath(import.meta.url)), 'protect', 'templates'); +const APP = process.cwd(); +const PS_DIR = join(APP, 'src/integrations/patchstack'); + +const read = (p: string) => readFileSync(p, 'utf8'); +const log = (msg: string) => console.log(`patchstack protect: ${msg}`); const CLIENT_TUNNEL = [ '', - " // PATCHSTACK auto-guard: in the browser, tunnel Supabase traffic through the app's own", - ' // server guard (same-origin) so payloads are inspected before they reach Supabase.', + " // PATCHSTACK: in the browser, tunnel Supabase traffic through the app's own server guard", + ' // (same-origin) so payloads are inspected before they reach Supabase.', " if (typeof window !== 'undefined') {", " const target = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);", " const method = init?.method ?? (input instanceof Request ? input.method : 'GET');", @@ -32,12 +36,12 @@ const CLIENT_TUNNEL = [ const START_IMPORTS = [ 'import { getRequest } from "@tanstack/react-start/server";', - 'import { GUARD_PATH, handleGuardRequest } from "@/integrations/patchstack/guard";', + 'import { GUARD_PATH, handleGuardRequest, inspectServerFn } from "@/integrations/patchstack/guard";', ].join('\n'); -const START_MIDDLEWARE = [ +const REQUEST_MIDDLEWARE_DEF = [ '', - '// Patchstack auto-guard: intercept the tunneled data traffic before anything else runs.', + '// Patchstack guard (browser tunnel): intercept tunneled Supabase traffic before anything else.', 'const patchstackGuard = createMiddleware().server(async ({ next }) => {', ' const request = getRequest();', ' if (request) {', @@ -49,17 +53,22 @@ const START_MIDDLEWARE = [ '', ].join('\n'); -const PS_DIR_REL = 'src/integrations/patchstack'; - -function log(msg: string): void { - console.log(`patchstack protect: ${msg}`); -} +const FUNCTION_MIDDLEWARE_DEF = [ + '', + '// Patchstack guard (server functions): inspect server-fn args before they reach the database,', + '// covering apps that mutate via TanStack server functions (which bypass the browser tunnel).', + 'const patchstackFunctionGuard = createMiddleware({ type: "function" }).server(async ({ next, data }) => {', + ' const blocked = await inspectServerFn(data);', + ' if (blocked) throw new Error(blocked.message);', + ' return next();', + '});', + '', +].join('\n'); -/** Returns true if this looks like a supported server-side app (TanStack Start + Supabase). */ export function detectSupportedStack(cwd: string): boolean { const pkgPath = join(cwd, 'package.json'); if (!existsSync(pkgPath)) return false; - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + const pkg = JSON.parse(read(pkgPath)); const deps = { ...pkg.dependencies, ...pkg.devDependencies }; return ( Boolean(deps['@tanstack/react-start']) && @@ -68,113 +77,86 @@ export function detectSupportedStack(cwd: string): boolean { ); } -export function generateManifest(cwd: string): void { - const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); - const deps: Record = { ...pkg.dependencies, ...pkg.devDependencies }; - const packages: Record = {}; - for (const name of Object.keys(deps)) { - let version = String(deps[name]).replace(/^[^\d]*/, ''); - const installed = join(cwd, 'node_modules', name, 'package.json'); - if (existsSync(installed)) { - try { - version = JSON.parse(readFileSync(installed, 'utf8')).version; - } catch { - /* keep the range-derived version */ - } - } - packages[name] = version; - } - mkdirSync(join(cwd, PS_DIR_REL), { recursive: true }); - const body = - '// Patchstack auto-guard manifest — generated from the lockfile.\n' + - '// Do not edit; regenerated by `patchstack-connect protect --manifest` (wired into prebuild).\n' + - 'export const manifest = ' + - JSON.stringify({ packages }, null, 2) + - ';\n'; - writeFileSync(join(cwd, PS_DIR_REL, 'manifest.js'), body); - log(`wrote manifest.js (${Object.keys(packages).length} packages)`); -} - function scaffold(cwd: string): void { - const dst = join(cwd, PS_DIR_REL); + const dst = join(cwd, 'src/integrations/patchstack'); mkdirSync(dst, { recursive: true }); - copyFileSync(join(TEMPLATES, 'engine.js'), join(dst, 'engine.js')); - copyFileSync(join(TEMPLATES, 'engine.d.ts'), join(dst, 'engine.d.ts')); - copyFileSync(join(TEMPLATES, 'manifest.d.ts'), join(dst, 'manifest.d.ts')); - copyFileSync(join(TEMPLATES, 'rules.json'), join(dst, 'rules.json')); copyFileSync(join(TEMPLATES, 'guard.ts'), join(dst, 'guard.ts')); - log('scaffolded engine.js (+types), rules.json, guard.ts'); + copyFileSync(join(TEMPLATES, 'rules.json'), join(dst, 'rules.json')); + log('scaffolded guard.ts + rules.json'); } function patchClient(cwd: string): void { const p = join(cwd, 'src/integrations/supabase/client.ts'); - let s = readFileSync(p, 'utf8'); - if (s.includes('x-ps-target')) { - log('client.ts already wired'); - return; - } + let s = read(p); + if (s.includes('x-ps-target')) return log('client.ts already wired'); const anchor = "headers.set('apikey', supabaseKey);"; - if (!s.includes(anchor)) { - log('client.ts anchor not found (template changed?) — skipping client patch'); - return; - } - s = s.replace(anchor, anchor + '\n' + CLIENT_TUNNEL); - writeFileSync(p, s); + if (!s.includes(anchor)) return log('client.ts anchor not found — skipping (template changed?)'); + writeFileSync(p, s.replace(anchor, anchor + '\n' + CLIENT_TUNNEL)); log('patched client.ts (tunnel Supabase through the guard)'); } function patchStart(cwd: string): void { const p = join(cwd, 'src/start.ts'); - let s = readFileSync(p, 'utf8'); - if (s.includes('patchstackGuard')) { - log('start.ts already wired'); - return; - } + let s = read(p); const importAnchor = 'import { createStart, createMiddleware } from "@tanstack/react-start";'; const exportAnchor = 'export const startInstance'; const rmAnchor = 'requestMiddleware: ['; - if (!s.includes(importAnchor) || !s.includes(exportAnchor) || !s.includes(rmAnchor)) { - log('start.ts anchors not found (template changed?) — skipping start patch'); - return; + if (!s.includes(importAnchor) || !s.includes(exportAnchor)) { + return log('start.ts anchors not found — skipping (template changed?)'); } - s = s.replace(importAnchor, importAnchor + '\n' + START_IMPORTS); - s = s.replace(exportAnchor, START_MIDDLEWARE + '\n' + exportAnchor); - s = s.replace(rmAnchor, rmAnchor + 'patchstackGuard, '); - writeFileSync(p, s); - log('patched start.ts (registered the guard as request middleware)'); -} -function wirePrebuild(cwd: string): void { - const p = join(cwd, 'package.json'); - const pkg = JSON.parse(readFileSync(p, 'utf8')); - pkg.scripts = pkg.scripts || {}; - const step = 'patchstack-connect protect --manifest'; - const prebuild: string = pkg.scripts.prebuild || ''; - if (prebuild.includes(step)) { - log('prebuild already refreshes the manifest'); - return; + // Each step is independently idempotent, so re-running `protect` (including after a connect + // upgrade that adds a new guard) reconciles only what's missing — never duplicates, never + // silently skips a newly-added piece. + const original = s; + + // Imports. + if (!s.includes('@/integrations/patchstack/guard')) { + s = s.replace(importAnchor, importAnchor + '\n' + START_IMPORTS); + } else if (!s.includes('inspectServerFn')) { + // Upgrade from a build that only wired the browser tunnel: pull in inspectServerFn. + s = s.replace( + 'import { GUARD_PATH, handleGuardRequest } from "@/integrations/patchstack/guard";', + 'import { GUARD_PATH, handleGuardRequest, inspectServerFn } from "@/integrations/patchstack/guard";', + ); } - pkg.scripts.prebuild = prebuild ? prebuild + ' && ' + step : step; - writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); - log('wired manifest refresh into prebuild'); -} -/** Full install (or manifest-only refresh). */ -export function runProtect(cwd: string, opts: { manifestOnly?: boolean } = {}): void { - if (opts.manifestOnly) { - generateManifest(cwd); - return; + // Middleware definitions (each only if its const isn't already present). + if (!s.includes('const patchstackGuard =')) { + s = s.replace(exportAnchor, REQUEST_MIDDLEWARE_DEF + '\n' + exportAnchor); + } + if (!s.includes('const patchstackFunctionGuard =')) { + s = s.replace(exportAnchor, FUNCTION_MIDDLEWARE_DEF + '\n' + exportAnchor); } + + // Register the browser-tunnel guard in requestMiddleware. + if (s.includes(rmAnchor) && !s.includes('requestMiddleware: [patchstackGuard')) { + s = s.replace(rmAnchor, rmAnchor + 'patchstackGuard, '); + } + + // Register the server-function guard in functionMiddleware (create the key if the app has none). + if (!s.includes('functionMiddleware: [patchstackFunctionGuard')) { + const fmAnchor = 'functionMiddleware: ['; + if (s.includes(fmAnchor)) { + s = s.replace(fmAnchor, fmAnchor + 'patchstackFunctionGuard, '); + } else if (s.includes(rmAnchor)) { + s = s.replace(rmAnchor, 'functionMiddleware: [patchstackFunctionGuard],\n ' + rmAnchor); + } + } + + if (s === original) return log('start.ts already wired'); + writeFileSync(p, s); + log('patched start.ts (guard registered as request + function middleware)'); +} + +/** Scaffold + wire the runtime guard into the app. */ +export function runProtect(cwd: string): void { if (!detectSupportedStack(cwd)) { - log( - 'runtime protection currently supports TanStack Start + Supabase apps; stack not detected — skipping.', - ); + log('runtime protection currently supports TanStack Start + Supabase apps; stack not detected — skipping.'); return; } scaffold(cwd); patchClient(cwd); patchStart(cwd); - generateManifest(cwd); - wirePrebuild(cwd); log('done — guard wired and always-on (blocks by default). Set PATCHSTACK_MODE=dry-run for log-only.'); } diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts new file mode 100644 index 0000000..cb74548 --- /dev/null +++ b/src/protect/protect.d.ts @@ -0,0 +1,51 @@ +// Public types for `@patchstack/connect/protect` (the vendored runtime is plain JS, so the +// declarations are hand-authored and shipped alongside dist/protect.js). + +export interface RuleBundle { + firewall: unknown[]; + whitelists: unknown[]; + whitelist_keys: Record; +} + +export interface Protection { + mode: "block" | "dry-run"; + rules: RuleBundle; + /** (request) => Response (403 when blocked) | null (allow / dry-run). */ + fetchGuard(): (request: Request) => Promise; + fetch(handler: (request: Request, ...rest: unknown[]) => unknown): (request: Request, ...rest: unknown[]) => Promise; + express(): (req: unknown, res: unknown, next: () => void) => void; + node(options?: { maxBodyBytes?: number }): (req: unknown, res: unknown, next: () => void) => void; +} + +export interface CreateProtectionOptions { + /** Default "dry-run". The scaffolded guard sets "block". */ + mode?: "block" | "dry-run"; + /** Explicit rule bundle (used as the token-less fallback). */ + rules?: unknown; + /** Patchstack WAF token — pull live per-site rules from the API. */ + token?: string; + baseUrl?: string; + /** Directory for the last-known-good rule cache. */ + cacheDir?: string; + onError?: (err: unknown) => void; + onDetect?: (detection: { mode: string; rule?: { id?: string }; message?: string }) => void; +} + +export function createProtection(options?: CreateProtectionOptions): Promise; + +export const GUARD_PATH: string; + +/** Browser-tunnel guard: evaluate then forward to the app's own Supabase project (SSRF-pinned). */ +export function createSupabaseGuard(opts: { + protection: Protection; + supabaseUrl?: string; + fetchImpl?: typeof fetch; +}): (request: Request) => Promise; + +/** + * Server-function guard: inspect a TanStack server function's decoded args against the same + * policy. Returns a block receipt to throw on (aborts the call before it writes), or null to allow. + */ +export function createServerFnGuard(opts: { + protection: Protection; +}): (data: unknown) => Promise<{ rule?: string; message: string } | null>; diff --git a/src/protect/runtime.js b/src/protect/runtime.js new file mode 100644 index 0000000..29a2f53 --- /dev/null +++ b/src/protect/runtime.js @@ -0,0 +1,234 @@ +// @patchstack/protect — "Protect = respond". +// +// One entry point that composes the node-waf engine + adapters with: +// - a rule source: an explicit bundle, or fetched from the Patchstack API (token), +// with a disk cache so the engine keeps working on last-known-good if the API is down +// - execution modes: 'dry-run' (detect + log, never block — the safe onramp) and +// 'block' (enforce). Default is 'dry-run'. +// - fail-open everywhere: a rule/engine error never blocks (or crashes) a request. +// +// Runtime guards: .express(), .node(), .fetch(handler) / .fetchGuard() — same policy, +// every runtime an AI builder deploys to. +// Vendored node-waf engine (this package is self-contained — no @patchstack/node-waf dep). +import { RuleEngine, PatchstackRuleClient } from './engine/index.js'; +import { fromFetchRequest } from './engine/fetch.js'; +import { fromNodeRequest } from './engine/node.js'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +// Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase). +export { createSupabaseGuard, GUARD_PATH } from './supabase-guard.js'; + +// Server-function guard. Modern Lovable apps mutate data through TanStack server functions +// (browser → server fn → server-side Supabase client), which bypass the browser-side tunnel the +// Supabase guard relies on. This inspects the decoded server-fn call args against the SAME policy +// by feeding them through fetchGuard (the args become the request body, so the engine resolves +// `post.` exactly as it does for a tunneled Supabase insert). Returns a block receipt +// { rule?, message } to throw on, or null to allow (also null in dry-run — the detection is still +// recorded by fetchGuard). Fail-open on any error. +export function createServerFnGuard({ protection }) { + const guard = protection.fetchGuard(); + return async (data) => { + let res; + try { + const req = new Request('https://patchstack.local/_serverfn', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(data ?? {}), + }); + res = await guard(req); + } catch { + return null; // fail open + } + if (!res) return null; // allowed (or dry-run) + let body = {}; + try { + body = await res.clone().json(); + } catch { + /* non-JSON block response */ + } + return { rule: body.rule, message: body.message || 'Blocked by Patchstack' }; + }; +} + +export async function createProtection(options = {}) { + const mode = options.mode === 'block' ? 'block' : 'dry-run'; + const onError = options.onError; + const onDetect = options.onDetect ?? defaultOnDetect; + + const bundle = await resolveRules(options); + const engine = new RuleEngine({ ...bundle, onError }); + + // Given an evaluation result, either enforce (block mode) or just record (dry-run). + const decide = (result, block, allow) => { + if (!result || !result.blocked) return allow(); + onDetect({ mode, rule: result.rule, message: result.message }); + return mode === 'block' ? block() : allow(); + }; + + const protection = { + mode, + rules: bundle, + + // (request) => Response | null (null = allow, caller proceeds) + fetchGuard() { + return async (request) => { + let result; + try { + result = engine.evaluate(await fromFetchRequest(request)); + } catch (err) { + onError?.(err); + return null; // fail open + } + return decide(result, () => blockResponse(result), () => null); + }; + }, + + // Wrap a fetch handler: export default { fetch: protection.fetch(app.fetch) } + fetch(handler) { + const guard = protection.fetchGuard(); + return async (request, ...rest) => (await guard(request)) ?? handler(request, ...rest); + }, + + // Express middleware (expects express-parsed req.query/req.body). + express() { + return (req, res, next) => { + let result; + try { + result = engine.evaluate(req); + } catch (err) { + onError?.(err); + return next(); + } + decide(result, () => res.status(403).json(blockBody(result)), () => next()); + }; + }, + + // Node / Connect middleware — buffers the body itself (no body-parser needed). + node(nodeOptions = {}) { + const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024; + return (req, res, next) => { + const chunks = []; + let size = 0; + let overflow = false; + req.on('data', (chunk) => { + size += chunk.length; + if (size > maxBytes) { + overflow = true; + return; + } + chunks.push(chunk); + }); + req.on('error', (err) => { + onError?.(err); + next(); + }); + req.on('end', () => { + const rawBody = overflow ? '' : Buffer.concat(chunks).toString('utf8'); + let result; + try { + result = engine.evaluate(fromNodeRequest(req, rawBody)); + } catch (err) { + onError?.(err); + return next(); + } + decide( + result, + () => { + res.statusCode = 403; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(blockBody(result))); + }, + () => next(), + ); + }); + }; + }, + }; + + return protection; +} + +// --- rule source -------------------------------------------------------- + +async function resolveRules(options) { + if (options.rules) { + return normalizeBundle(options.rules); + } + + if (options.token) { + const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl }); + const res = await client.getRules(); + if (res.success) { + const bundle = normalizeBundle(res); + cacheWrite(options.cacheDir, bundle); + return bundle; + } + const cached = cacheRead(options.cacheDir); + if (cached) { + options.onError?.(new Error(`rule fetch failed (${res.error}); using cached bundle`)); + return normalizeBundle(cached); + } + options.onError?.(new Error(`rule fetch failed (${res.error}); no cache — running with no rules`)); + return emptyBundle(); + } + + return emptyBundle(); +} + +function normalizeBundle(b) { + return { + firewall: Array.isArray(b.firewall) ? b.firewall : [], + whitelists: Array.isArray(b.whitelists) ? b.whitelists : [], + whitelist_keys: b.whitelist_keys ?? {}, + }; +} + +function emptyBundle() { + return { firewall: [], whitelists: [], whitelist_keys: {} }; +} + +function cachePath(dir) { + return join(dir, 'patchstack-rules.json'); +} + +function cacheWrite(dir, bundle) { + if (!dir) return; + try { + mkdirSync(dir, { recursive: true }); + writeFileSync(cachePath(dir), JSON.stringify(bundle)); + } catch { + /* cache is best-effort */ + } +} + +function cacheRead(dir) { + if (!dir) return null; + try { + return JSON.parse(readFileSync(cachePath(dir), 'utf8')); + } catch { + return null; + } +} + +// --- responses ---------------------------------------------------------- + +function blockBody(result) { + return { + error: 'Blocked by Patchstack', + rule: result.rule?.id, + message: result.message, + }; +} + +function blockResponse(result) { + return new Response(JSON.stringify(blockBody(result)), { + status: 403, + headers: { 'content-type': 'application/json' }, + }); +} + +function defaultOnDetect({ mode, rule, message }) { + const tag = mode === 'block' ? 'BLOCK' : 'DETECT (dry-run)'; + console.warn(`[patchstack] ${tag} rule=${rule?.id ?? '?'} ${message ?? ''}`.trim()); +} diff --git a/src/protect/supabase-guard.js b/src/protect/supabase-guard.js new file mode 100644 index 0000000..09c8300 --- /dev/null +++ b/src/protect/supabase-guard.js @@ -0,0 +1,78 @@ +// Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase). +// +// The vibe-coded app's browser talks straight to Supabase, bypassing the app's own server — +// so a normal in-app WAF never sees the data traffic. The installer patches the generated +// Supabase client to tunnel every call through this guard (running in the app's own server / +// Worker). The guard runs the Patchstack protection policy on the tunneled request, then +// forwards it to Supabase — pinned to the app's own project so it can't be turned into an +// open proxy (SSRF). +// +// The heavy lifting (rule evaluation, dry-run/block, fail-open) is `protection.fetchGuard()` +// from `createProtection` — this module is just the Supabase-specific tunnel around it. + +export const GUARD_PATH = '/_patchstack/guard'; + +// Headers we must not copy verbatim when re-emitting the upstream response. +const HOP_BY_HOP = new Set(['content-encoding', 'content-length', 'transfer-encoding', 'connection']); + +/** + * @param {object} opts + * @param {{ fetchGuard: () => (req: Request) => Promise }} opts.protection a createProtection() result + * @param {string|undefined} opts.supabaseUrl the app's Supabase project URL (server-side env) — the only allowed forward target + * @param {typeof fetch} [opts.fetchImpl] injectable fetch (tests) + * @returns {(request: Request) => Promise} + */ +export function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch }) { + const guard = protection.fetchGuard(); + const allowedOrigin = supabaseUrl ? new URL(supabaseUrl).origin : null; + + return async function handleGuardRequest(request) { + const target = request.headers.get('x-ps-target'); + if (!target) return new Response('patchstack: missing x-ps-target', { status: 400 }); + + let targetUrl; + try { + targetUrl = new URL(target); + } catch { + return new Response('patchstack: invalid target', { status: 400 }); + } + + // SSRF pin: only ever forward to the app's own Supabase project (origin from server-side + // env, never the client-supplied header). Anything else — internal hosts, cloud metadata, + // a different scheme — is rejected before any outbound request. + if (!allowedOrigin || targetUrl.protocol !== 'https:' || targetUrl.origin !== allowedOrigin) { + return new Response('patchstack: target not allowed', { status: 403 }); + } + + const hasBody = request.method !== 'GET' && request.method !== 'HEAD'; + const bodyText = hasBody ? await request.text() : ''; + + // Evaluate the tunneled call against the policy. fetchGuard returns a 403 Response when it + // blocks (block mode + match), or null to allow (allow, or dry-run — it records via onDetect). + const evalReq = new Request(targetUrl.toString(), { + method: request.method, + headers: request.headers, + body: hasBody ? bodyText : undefined, + }); + const blocked = await guard(evalReq); + if (blocked) return blocked; + + // Allowed → forward to Supabase, server-side. + const forwardHeaders = new Headers(request.headers); + forwardHeaders.delete('x-ps-target'); + forwardHeaders.delete('host'); + const upstream = await fetchImpl(targetUrl.toString(), { + method: request.method, + headers: forwardHeaders, + body: hasBody ? bodyText : undefined, + redirect: 'manual', + }); + + const outHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value); + }); + const buf = await upstream.arrayBuffer(); + return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders }); + }; +} diff --git a/src/protect/templates/engine.d.ts b/src/protect/templates/engine.d.ts deleted file mode 100644 index 1e92123..0000000 --- a/src/protect/templates/engine.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -export type Action = "ALLOW" | "LOG" | "BLOCK" | "REDIRECT"; - -export declare const ACTIONS: { - ALLOW: "ALLOW"; - LOG: "LOG"; - BLOCK: "BLOCK"; - REDIRECT: "REDIRECT"; -}; - -/** A normalized snapshot of one request. The framework guard builds this. */ -export interface RequestContext { - method: string; - url: string; - headers: Record; - /** Parsed request payload; rule parameters read from here (e.g. "insert.title"). */ - body: Record; - ip?: string; -} - -export interface RuleCondition { - parameter: string | string[]; - match: { type: "inline_xss" | "contains" | "equals" | string; value?: unknown }; - mutations?: string[]; -} - -/** Only apply the rule if the app has this package at a vulnerable version. */ -export interface PackageCond { - package: string; - vulnerable_versions?: string[]; -} - -export interface Rule { - id: string; - title?: string; - vulnerability_id?: string; - package_cond?: PackageCond; - rule_v2: RuleCondition[]; -} - -/** The app's installed package list, so package_cond can gate. */ -export interface Manifest { - packages: Record; -} - -export interface Verdict { - matched: boolean; - action: Action; - rule_id: string | null; - vulnerability_id: string | null; - package: string | null; - version: string | null; - explain: string[]; - trace: unknown[]; -} - -export declare function evaluate(ctx: RequestContext, rules: Rule[], manifest?: Manifest): Verdict; -export declare function urldecode(value: string): string; diff --git a/src/protect/templates/engine.js b/src/protect/templates/engine.js deleted file mode 100644 index 5a2982d..0000000 --- a/src/protect/templates/engine.js +++ /dev/null @@ -1,137 +0,0 @@ -// @patchstack/protect — the runtime protection engine. -// -// One pure function: evaluate(ctx, rules, manifest) -> Verdict. -// No framework coupling — the framework adapter (the guard) builds `ctx` and acts on the -// Verdict. This is the offsite-frozen contract between Group 1 (engine) and Group 2 (surface). -// -// SCOPE OF THIS BUILD: implements the two demo-critical pieces from the prototype — -// - `inline_xss` (ps-node never implemented it; npm XSS attacks need it) -// - `package_cond` (the line between virtual patching and a dumb wall) -// plus `equals` / `contains` and the `urldecode` mutation. The full rule_v2 zoo -// (AND/OR, nested rules, whitelists, the ~15 other match types) lands when the -// ps-node RuleEngine is ported in — this file keeps the same signature so that swap -// is drop-in. - -/** @type {{ALLOW:'ALLOW',LOG:'LOG',BLOCK:'BLOCK',REDIRECT:'REDIRECT'}} */ -export const ACTIONS = { ALLOW: "ALLOW", LOG: "LOG", BLOCK: "BLOCK", REDIRECT: "REDIRECT" }; - -/** Iteratively URL-decode (catches %2527 -> %27 -> '). Safe on malformed input. */ -export function urldecode(value) { - let out = String(value); - for (let i = 0; i < 5; i++) { - let next; - try { - next = decodeURIComponent(out.replace(/\+/g, " ")); - } catch { - break; - } - if (next === out) break; - out = next; - } - return out; -} - -const MUTATIONS = { urldecode }; - -function applyMutations(value, mutations) { - let v = String(value ?? ""); - for (const m of mutations ?? []) { - if (MUTATIONS[m]) v = MUTATIONS[m](v); - } - return v; -} - -// Heuristic script-injection detector. Deliberately simple and readable for the demo; -// the ported engine replaces this with the audited ps-node inline_xss implementation. -const XSS_PATTERNS = [ - /<\s*script\b/i, - /<\s*\/\s*script\s*>/i, - /\bon\w+\s*=/i, // onerror=, onload=, ... - /javascript\s*:/i, - /<\s*img\b[^>]*\bon\w+\s*=/i, - /<\s*svg\b[^>]*\bon\w+\s*=/i, - /\bdata\s*:\s*text\/html/i, -]; - -function looksLikeInlineXss(value) { - const v = String(value ?? ""); - return XSS_PATTERNS.some((re) => re.test(v)); -} - -// Resolve a rule parameter like "insert.title" or "body.content" against ctx. -// The guard normalizes framework specifics into ctx.body; parameters read from there. -function resolveParameter(ctx, parameter) { - const [, ...rest] = String(parameter).split("."); - const key = rest.length ? rest.join(".") : parameter; - const body = ctx?.body ?? {}; - if (key in body) return body[key]; - // shallow scan for nested payloads (e.g. { record: { title } }) - for (const v of Object.values(body)) { - if (v && typeof v === "object" && key in v) return v[key]; - } - return undefined; -} - -function matchCondition(ctx, cond) { - const params = Array.isArray(cond.parameter) ? cond.parameter : [cond.parameter]; - for (const param of params) { - const raw = resolveParameter(ctx, param); - if (raw == null) continue; - const value = applyMutations(raw, cond.mutations); - const type = cond.match?.type; - let hit = false; - if (type === "inline_xss") hit = looksLikeInlineXss(value); - else if (type === "contains") hit = value.includes(cond.match.value); - else if (type === "equals") hit = value === cond.match.value; - if (hit) { - return { param, type, value }; - } - } - return null; -} - -// package_cond: only fire if the app actually has the vulnerable package@version. -// If no manifest is supplied, fail-open on the gate (assume present) — detection already -// flagged it; the guard can tighten this once it passes the real manifest. -function packageCondSatisfied(pkgCond, manifest) { - if (!pkgCond) return true; - if (!manifest || !manifest.packages) return true; - const installed = manifest.packages[pkgCond.package]; - if (installed == null) return false; - if (!pkgCond.vulnerable_versions) return true; - return pkgCond.vulnerable_versions.includes(installed); -} - -/** - * Evaluate one request against the rule set. - * @param {import('./index.js').RequestContext} ctx - * @param {import('./index.js').Rule[]} rules - * @param {import('./index.js').Manifest} [manifest] - * @returns {import('./index.js').Verdict} - */ -export function evaluate(ctx, rules, manifest) { - const trace = []; - for (const rule of rules ?? []) { - if (!packageCondSatisfied(rule.package_cond, manifest)) { - trace.push({ rule_id: rule.id, skipped: "package_cond not satisfied" }); - continue; - } - for (const cond of rule.rule_v2 ?? []) { - const m = matchCondition(ctx, cond); - if (m) { - return { - matched: true, - action: ACTIONS.BLOCK, - rule_id: rule.id, - vulnerability_id: rule.vulnerability_id ?? null, - package: rule.package_cond?.package ?? null, - version: manifest?.packages?.[rule.package_cond?.package] ?? null, - explain: [`${m.param} matched ${m.type}${cond.mutations?.length ? ` after ${cond.mutations.join("+")}` : ""}`], - trace, - }; - } - } - trace.push({ rule_id: rule.id, matched: false }); - } - return { matched: false, action: ACTIONS.ALLOW, rule_id: null, vulnerability_id: null, package: null, version: null, explain: [], trace }; -} diff --git a/src/protect/templates/guard.ts b/src/protect/templates/guard.ts index 6ca0a56..263e691 100644 --- a/src/protect/templates/guard.ts +++ b/src/protect/templates/guard.ts @@ -1,102 +1,59 @@ -// Patchstack auto-guard — installed automatically by "add Patchstack"; the vibe coder never -// touches this. It runs in the app's own server (the Cloudflare Worker). The patched Supabase -// client tunnels every browser data call here; we inspect the payload against the rules, then -// either block it (virtual patch) or forward it to real Supabase. -import { evaluate, type Rule } from "./engine.js"; -import rulesData from "./rules.json"; -import { manifest } from "./manifest.js"; - -export const GUARD_PATH = "/_patchstack/guard"; - -// Headers that must not be copied verbatim when we re-emit the upstream response. -const HOP_BY_HOP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]); - -function mode(): "dry-run" | "block" { - // Always-on: protection blocks by default. Only an explicit PATCHSTACK_MODE=dry-run - // downgrades to log-only (for testing) — there is no silent dry-run. - return process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; +// Patchstack runtime protection — installed by `patchstack-connect protect`; the vibe coder +// never touches this. It runs in the app's own server (e.g. the Cloudflare Worker) and covers +// both ways a Lovable app talks to its database: +// - browser → Supabase directly: the generated Supabase client is patched to tunnel every call +// through handleGuardRequest (registered as request middleware in src/start.ts). +// - browser → TanStack server function → Supabase: inspectServerFn checks the server-fn args +// (registered as function middleware in src/start.ts) before anything is written. +// Either way Patchstack sees the traffic and runs the same policy. +// +// Always-on by default (blocks). Rules come from the Patchstack API per-site (cached); the +// bundled rules.json is only a fallback for before a token is configured. The engine ships +// inside @patchstack/connect — nothing else to install. +import { + createProtection, + createSupabaseGuard, + createServerFnGuard, + GUARD_PATH, +} from "@patchstack/connect/protect"; +import fallbackRules from "./rules.json"; + +export { GUARD_PATH }; + +// One shared protection policy for both guards (rules load once). +let _protection: Awaited> | undefined; +async function getProtection() { + if (!_protection) { + // Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only. + const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; + const token = process.env.PATCHSTACK_WAF_TOKEN; + _protection = await createProtection( + token + ? { token, mode, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached) + : { rules: fallbackRules as never, mode }, // demo fallback until a token is set + ); + } + return _protection; } +// Request-middleware path: the browser tunnels its direct Supabase calls here. +let _handle: ((request: Request) => Promise) | undefined; export async function handleGuardRequest(request: Request): Promise { - const target = request.headers.get("x-ps-target"); - if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 }); - - // SSRF guard: the client names the target, but we only ever forward to the app's own - // Supabase project (origin known server-side). Anything else — internal hosts, cloud - // metadata, a different scheme — is rejected before we make any outbound request. - const supabaseUrl = process.env.SUPABASE_URL; - let targetUrl: URL; - try { - targetUrl = new URL(target); - } catch { - return new Response("patchstack: invalid target", { status: 400 }); - } - if (!supabaseUrl || targetUrl.protocol !== "https:" || targetUrl.origin !== new URL(supabaseUrl).origin) { - return new Response("patchstack: target not allowed", { status: 403 }); - } - - const hasBody = request.method !== "GET" && request.method !== "HEAD"; - const bodyText = hasBody ? await request.text() : ""; - - let record: Record = {}; - if (bodyText) { - try { - const parsed = JSON.parse(bodyText); - record = Array.isArray(parsed) ? (parsed[0] ?? {}) : parsed; - } catch { - // not JSON — leave record empty; the rule simply won't match - } - } - - const ctx = { - method: request.method, - url: target, - headers: Object.fromEntries(request.headers), - // expose the record under a few shapes so rule parameters like "insert.title" resolve - body: { ...record, insert: record, update: record }, - ip: request.headers.get("x-forwarded-for") ?? "", - }; - - const t0 = performance.now(); - const verdict = evaluate(ctx, (rulesData as { firewall: Rule[] }).firewall, manifest); - const latencyMs = Math.round((performance.now() - t0) * 100) / 100; - - // Always log the flag (this is the "dry-run sees it too" behavior). - const decision = verdict.matched ? `MATCH ${verdict.rule_id}` : "clean"; - console.log(`[patchstack] ${request.method} ${new URL(target).pathname} -> ${decision} · mode=${mode()}`); - - if (verdict.matched && mode() === "block") { - const receipt = { - blocked: true, - rule_id: verdict.rule_id, - vulnerability_id: verdict.vulnerability_id, - package: verdict.package, - version: verdict.version, - matched_conditions: verdict.explain, - latency_ms: latencyMs, - }; - console.log("[patchstack] BLOCKED", JSON.stringify(receipt)); - return new Response(JSON.stringify(receipt), { - status: 403, - headers: { "content-type": "application/json", "x-patchstack": "blocked" }, + if (!_handle) { + _handle = createSupabaseGuard({ + protection: await getProtection(), + supabaseUrl: process.env.SUPABASE_URL, }); } + return _handle(request); +} - // Forward to real Supabase, server-side. - const forwardHeaders = new Headers(request.headers); - forwardHeaders.delete("x-ps-target"); - forwardHeaders.delete("host"); - const upstream = await fetch(target, { - method: request.method, - headers: forwardHeaders, - body: hasBody ? bodyText : undefined, - redirect: "manual", - }); - - const outHeaders = new Headers(); - upstream.headers.forEach((value, key) => { - if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value); - }); - const buf = await upstream.arrayBuffer(); - return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders }); +// Function-middleware path: inspect a server function's decoded args before the handler runs. +// Returns a block receipt (throw on it to abort the call) or null to allow. +let _inspect: ((data: unknown) => Promise<{ rule?: string; message: string } | null>) | undefined; +export async function inspectServerFn(data: unknown): Promise<{ rule?: string; message: string } | null> { + if (!_inspect) { + _inspect = createServerFnGuard({ protection: await getProtection() }); + } + return _inspect(data); } diff --git a/src/protect/templates/manifest.d.ts b/src/protect/templates/manifest.d.ts deleted file mode 100644 index 5331d78..0000000 --- a/src/protect/templates/manifest.d.ts +++ /dev/null @@ -1 +0,0 @@ -export declare const manifest: { packages: Record }; diff --git a/src/protect/templates/rules.json b/src/protect/templates/rules.json index 599e17b..f9ae41c 100644 --- a/src/protect/templates/rules.json +++ b/src/protect/templates/rules.json @@ -1,22 +1,19 @@ { - "_comment": "DEMO SCAFFOLD — static stub; the real per-site rules endpoint does not exist in saas yet. CVE-2022-21681 is a real marked advisory affecting <4.0.10 (demo installs 4.0.0). The demo uses an XSS-shaped payload for visual clarity; exact payload<->CVE alignment is pinned with Group 1's corpus.", + "_comment": "Demo fallback rules — used only when PATCHSTACK_WAF_TOKEN is unset. In production the guard fetches per-site rules from the Patchstack API (api.patchstack.com/api/get-rules/3, cached to disk). CVE id is representative until aligned with the real corpus.", "firewall": [ { "id": "rm-npm-0001", "title": "Block stored XSS via vulnerable markdown renderer (marked)", "vulnerability_id": "CVE-2022-21681", - "package_cond": { - "package": "marked", - "vulnerable_versions": ["4.0.0"] - }, "rule_v2": [ { - "parameter": ["insert.title", "body.title", "update.title"], + "parameter": "post.title", "mutations": ["urldecode"], "match": { "type": "inline_xss" } } ] } ], - "whitelists": [] + "whitelists": [], + "whitelist_keys": {} } diff --git a/tests/protect.test.ts b/tests/protect.test.ts new file mode 100644 index 0000000..41f382c --- /dev/null +++ b/tests/protect.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +// The vendored runtime (node-waf engine + createProtection + Supabase guard), +// exported as @patchstack/connect/protect. +import { createProtection, createSupabaseGuard, createServerFnGuard } from '../src/protect/runtime.js'; + +const rules = { + firewall: [ + { + id: 'rm-npm-0001', + title: 'Block stored XSS via vulnerable markdown renderer (marked)', + rule_v2: [{ parameter: 'post.title', mutations: ['urldecode'], match: { type: 'inline_xss' } }], + }, + ], + whitelists: [], + whitelist_keys: {}, +}; + +const SUPABASE = 'https://proj.supabase.co'; +const TASKS = `${SUPABASE}/rest/v1/tasks`; +const okFetch = async () => + new Response('[{"id":"1"}]', { status: 201, headers: { 'content-type': 'application/json' } }); + +function insertReq(title: string, headers: Record = {}) { + return new Request('https://app.example.com/_patchstack/guard', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-ps-target': TASKS, ...headers }, + body: JSON.stringify({ title }), + }); +} + +describe('@patchstack/connect/protect (vendored engine + supabase guard)', () => { + it('block mode: exploit → 403, benign → forwarded 201', async () => { + const protection = await createProtection({ rules, mode: 'block' }); + const handle = createSupabaseGuard({ protection, supabaseUrl: SUPABASE, fetchImpl: okFetch }); + expect((await handle(insertReq(''))).status).toBe(403); + expect((await handle(insertReq('buy milk'))).status).toBe(201); + }); + + it('SSRF: disallowed target → 403, never forwarded', async () => { + let forwarded = false; + const protection = await createProtection({ rules, mode: 'block' }); + const handle = createSupabaseGuard({ + protection, + supabaseUrl: SUPABASE, + fetchImpl: async () => { + forwarded = true; + return new Response('x'); + }, + }); + const res = await handle(insertReq('buy milk', { 'x-ps-target': 'http://169.254.169.254/latest/meta-data/' })); + expect(res.status).toBe(403); + expect(forwarded).toBe(false); + }); + + it('dry-run: exploit is NOT blocked (forwarded), detection recorded', async () => { + const detections: unknown[] = []; + const protection = await createProtection({ rules, mode: 'dry-run', onDetect: (d: unknown) => detections.push(d) }); + const handle = createSupabaseGuard({ protection, supabaseUrl: SUPABASE, fetchImpl: okFetch }); + const res = await handle(insertReq('')); + expect(res.status).toBe(201); + expect(detections.length).toBe(1); + }); +}); + +describe('createServerFnGuard (TanStack server-function path)', () => { + it('block mode: exploit args → receipt, benign args → null', async () => { + const protection = await createProtection({ rules, mode: 'block' }); + const guard = createServerFnGuard({ protection }); + const blocked = await guard({ title: '' }); + expect(blocked?.rule).toBe('rm-npm-0001'); + expect(await guard({ title: 'buy milk' })).toBeNull(); + }); + + it('dry-run: exploit args → null (not blocked), detection recorded', async () => { + const detections: unknown[] = []; + const protection = await createProtection({ + rules, + mode: 'dry-run', + onDetect: (d: unknown) => detections.push(d), + }); + const guard = createServerFnGuard({ protection }); + expect(await guard({ title: '' })).toBeNull(); + expect(detections.length).toBe(1); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index a4ebad6..15ec4b9 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -17,4 +17,13 @@ export default defineConfig([ target: 'node18', banner: { js: '#!/usr/bin/env node' }, }, + { + // Vendored runtime protection engine (node-waf + createProtection + Supabase guard), + // exported as @patchstack/connect/protect. The scaffolded app guard imports this. + entry: { protect: 'src/protect/runtime.js' }, + format: ['esm', 'cjs'], + clean: false, + sourcemap: true, + target: 'node18', + }, ]);