Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/protect/engine/pulse-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
const DEFAULT_BASE_URL = 'https://api.patchstack.com/monitor/pulse';
const DEFAULT_CACHE_TTL = 300_000;

// Per-site rules client for Pulse (npm/JS) apps. Public endpoint — the site UUID is the only
// credential, passed in the path. Fail-open: any error returns success:false + empty rules so
// createProtection falls back to the disk cache or the bundled rules.
export class PulseRuleClient {
#siteUuid;
#baseUrl;
#cacheTtl;
#cache = null;
#cacheTime = null;

constructor({ siteUuid, baseUrl, cacheTtl } = {}) {
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL;
this.#cacheTtl = cacheTtl ?? DEFAULT_CACHE_TTL;
if (!this.#siteUuid) {
throw new Error('Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.');
}
}

async getRules() {
const now = Date.now();
if (this.#cache && this.#cacheTime && now - this.#cacheTime < this.#cacheTtl) {
return this.#cache;
}
const url = `${this.#baseUrl}/rules/${encodeURIComponent(this.#siteUuid)}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) {
return { success: false, error: `API returned ${response.status}`, 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;
}
}
27 changes: 27 additions & 0 deletions src/protect/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,32 @@ function scaffold(cwd: string): void {
log('scaffolded guard.ts + rules.json');
}

// Bake the site UUID from .patchstackrc.json (written by `patchstack-connect scan`) into the
// scaffolded guard, so the deployed Worker calls the live Pulse rules API with zero user config.
// Left as the inert placeholder (guard falls back to PATCHSTACK_SITE_UUID env / bundled rules)
// when the app hasn't been scanned yet or the file can't be read.
function bakeSiteUuid(cwd: string): void {
const rc = join(cwd, '.patchstackrc.json');
if (!existsSync(rc)) return log('no .patchstackrc.json — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback');
let uuid: string | undefined;
try {
uuid = JSON.parse(read(rc)).siteUuid;
} catch {
return log('.patchstackrc.json unreadable — skipping site-UUID bake');
}
// Guard on UUID format so a malformed value falls through to the inert placeholder rather
// than baking junk into a TS string literal (broken build / replace-token hazards).
if (!uuid || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) {
return log('.patchstackrc.json siteUuid missing or malformed — guard uses PATCHSTACK_SITE_UUID env or the bundled fallback');
}
const p = join(cwd, 'src/integrations/patchstack/guard.ts');
if (!existsSync(p)) return;
const s = read(p);
if (!s.includes('__PATCHSTACK_SITE_UUID__')) return log('guard.ts site UUID already baked');
writeFileSync(p, s.replace('__PATCHSTACK_SITE_UUID__', uuid));
log('baked site UUID into guard.ts — live rules from the Patchstack API');
}

function patchClient(cwd: string): void {
const p = join(cwd, 'src/integrations/supabase/client.ts');
let s = read(p);
Expand Down Expand Up @@ -162,6 +188,7 @@ export function runProtect(cwd: string): void {
return;
}
scaffold(cwd);
bakeSiteUuid(cwd);
patchClient(cwd);
patchStart(cwd);
log('done — guard wired and always-on (blocks by default). Set PATCHSTACK_MODE=dry-run for log-only.');
Expand Down
4 changes: 4 additions & 0 deletions src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export interface CreateProtectionOptions {
/** Patchstack WAF token — pull live per-site rules from the API. */
token?: string;
baseUrl?: string;
/** Pulse site UUID — pull live per-site rules from the Pulse rules API (cached). */
siteUuid?: string;
/** Override the Pulse rules API base URL. */
pulseRulesUrl?: string;
/** Directory for the last-known-good rule cache. */
cacheDir?: string;
/** Override the default response-phase (secret-leak) rule set. */
Expand Down
29 changes: 27 additions & 2 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// 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 { PulseRuleClient } from './engine/pulse-client.js';
import { fromFetchRequest } from './engine/fetch.js';
import { fromNodeRequest } from './engine/node.js';
import { installEgressGuard } from './egress.js';
Expand All @@ -21,6 +22,9 @@ import { join } from 'node:path';
// Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase).
export { createSupabaseGuard, GUARD_PATH } from './supabase-guard.js';

// Per-site live rule client (Pulse). Re-exported for callers/tests that want to use it directly.
export { PulseRuleClient };

// 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
Expand Down Expand Up @@ -403,8 +407,25 @@ function leakResponse() {
// --- rule source --------------------------------------------------------

async function resolveRules(options) {
if (options.rules) {
return normalizeBundle(options.rules);
if (options.siteUuid) {
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl });
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(`pulse rule fetch failed (${res.error}); using cached bundle`));
return normalizeBundle(cached);
}
if (options.rules) {
options.onError?.(new Error(`pulse rule fetch failed (${res.error}); using bundled fallback`));
return normalizeBundle(options.rules);
}
options.onError?.(new Error(`pulse rule fetch failed (${res.error}); no cache — running with no rules`));
return emptyBundle();
}

if (options.token) {
Expand All @@ -424,6 +445,10 @@ async function resolveRules(options) {
return emptyBundle();
}

if (options.rules) {
return normalizeBundle(options.rules);
}

return emptyBundle();
}

Expand Down
12 changes: 9 additions & 3 deletions src/protect/templates/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import {
} from "@patchstack/connect/protect";
import fallbackRules from "./rules.json";

// Baked by `patchstack-connect protect` from .patchstackrc.json (empty if the app isn't scanned yet).
const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";

export { GUARD_PATH };

// One shared protection policy for both guards (rules load once).
Expand All @@ -29,6 +32,7 @@ async function getProtection() {
// 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;
const siteUuid = PS_SITE_UUID.startsWith("__") ? process.env.PATCHSTACK_SITE_UUID : PS_SITE_UUID;
// Egress SSRF screening: block the app's outbound calls to internal / metadata addresses,
// but never its own Supabase project.
let allowHosts: string[] = [];
Expand All @@ -39,9 +43,11 @@ async function getProtection() {
}
const common = { mode, egress: true, allowHosts };
_protection = await createProtection(
token
? { ...common, token, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached)
: { ...common, rules: fallbackRules as never }, // demo fallback until a token is set
siteUuid
? { ...common, siteUuid, rules: fallbackRules as never, cacheDir: ".patchstack" } // live per-site rules; bundled = offline fallback
: token
? { ...common, token, cacheDir: ".patchstack" } // live per-site WAF rules from the Patchstack API (cached)
: { ...common, rules: fallbackRules as never }, // demo fallback until a site UUID / token is set
);
}
return _protection;
Expand Down
108 changes: 108 additions & 0 deletions tests/protect/dave-example-rule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, it, vi } from 'vitest';
import { createProtection } from '../../src/protect/runtime.js';

// Patchstack's real rule_v2 bundle: block if `mytestparameter` is PRESENT (isset)
// OR if `hithere` CONTAINS `allgood`. Two plain (non-inclusive) conditions ->
// OR-combined by #evaluateRule (engine.js:310-314): ANY match -> block.
const bundle = {
firewall: [
{
id: 'rm-npm-test-0001',
title: "Dave's example test rule",
rule_v2: [
{
parameter: 'get.mytestparameter',
match: {
type: 'isset',
},
},
{
parameter: 'get.hithere',
match: {
type: 'contains',
value: 'allgood',
},
},
],
},
],
whitelists: [],
whitelist_keys: {},
};

// GET request carrying a query string — params reach the engine as get.<name>
// via fromFetchRequest -> url.searchParams (src/protect/engine/fetch.js:22-29).
function getReq(qs: string) {
return new Request(`https://app.example.com/api/thing?${qs}`, { method: 'GET' });
}

describe("Dave's example rule — inline bundle via fetchGuard", () => {
it('BLOCKED: ?mytestparameter=1 (isset match) -> 403', async () => {
const protection = await createProtection({ rules: bundle, mode: 'block' });
const guard = protection.fetchGuard(); // (request) => Promise<Response | null>

const res = await guard(getReq('mytestparameter=1'));
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
});

it('BLOCKED: ?hithere=xxallgoodxx (contains match) -> 403', async () => {
const protection = await createProtection({ rules: bundle, mode: 'block' });
const guard = protection.fetchGuard();

const res = await guard(getReq('hithere=xxallgoodxx'));
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
});

it('ALLOWED: ?hithere=nope and NO mytestparameter -> null (proves engine is not blocking everything)', async () => {
const protection = await createProtection({ rules: bundle, mode: 'block' });
const guard = protection.fetchGuard();

const res = await guard(getReq('hithere=nope'));
expect(res).toBeNull();
});

it('OR semantics: each condition blocks on its own, independent of the other', async () => {
const protection = await createProtection({ rules: bundle, mode: 'block' });
const guard = protection.fetchGuard();

// Only mytestparameter present (hithere absent) -> isset fires alone.
const onlyIsset = await guard(getReq('mytestparameter=anything'));
expect(onlyIsset).not.toBeNull();
expect(onlyIsset!.status).toBe(403);

// Only hithere=...allgood... present (mytestparameter absent) -> contains fires alone.
const onlyContains = await guard(getReq('hithere=allgood'));
expect(onlyContains).not.toBeNull();
expect(onlyContains!.status).toBe(403);
});
});

describe("Dave's example rule — full production fetch path (PulseRuleClient)", () => {
it('BLOCKED via live Pulse fetch: ?mytestparameter=1 -> 403', async () => {
const fetchMock = vi.fn(async () =>
new Response(JSON.stringify(bundle), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);

try {
const protection = await createProtection({
siteUuid: 'test-site',
pulseRulesUrl: 'https://x.test/monitor/pulse',
mode: 'block',
});
const guard = protection.fetchGuard();

const res = await guard(getReq('mytestparameter=1'));
expect(fetchMock).toHaveBeenCalled();
expect(res).not.toBeNull();
expect(res!.status).toBe(403);
} finally {
vi.restoreAllMocks();
}
});
});
46 changes: 46 additions & 0 deletions tests/protect/protect-install.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { runProtect } from '../../src/protect/install.js';

let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'ps-install-'));
mkdirSync(join(dir, 'src/integrations/supabase'), { recursive: true });
writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { '@tanstack/react-start': '^1' } }));
writeFileSync(join(dir, 'src/start.ts'),
'import { createStart, createMiddleware } from "@tanstack/react-start";\n' +
'export const startInstance = createStart(() => ({ functionMiddleware: [], requestMiddleware: [] }));\n');
writeFileSync(join(dir, 'src/integrations/supabase/client.ts'),
"const headers = new Headers();\n headers.set('apikey', supabaseKey);\n");
// A real site UUID, matching what `patchstack-connect scan` actually writes.
writeFileSync(join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: '3f1a9c2e-1b4d-4c8a-9e2f-7a6b5c4d3e2f' }));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));

describe('runProtect bakes the site UUID', () => {
it('replaces the placeholder in guard.ts with the .patchstackrc.json uuid', () => {
runProtect(dir);
const guard = readFileSync(join(dir, 'src/integrations/patchstack/guard.ts'), 'utf8');
expect(guard).toContain('3f1a9c2e-1b4d-4c8a-9e2f-7a6b5c4d3e2f');
expect(guard).not.toContain('__PATCHSTACK_SITE_UUID__');
});

it('leaves the placeholder inert (empty) when there is no .patchstackrc.json', () => {
rmSync(join(dir, '.patchstackrc.json'));
runProtect(dir);
const guard = readFileSync(join(dir, 'src/integrations/patchstack/guard.ts'), 'utf8');
// unbaked placeholder must not crash the guard: it is treated as "no uuid"
expect(guard).toContain('__PATCHSTACK_SITE_UUID__');
});

it('leaves the placeholder inert when the siteUuid is malformed', () => {
// A non-UUID value must not be baked into the TS literal (broken build / replace-token hazard).
writeFileSync(join(dir, '.patchstackrc.json'), JSON.stringify({ siteUuid: 'not-a-uuid"; drop()' }));
runProtect(dir);
const guard = readFileSync(join(dir, 'src/integrations/patchstack/guard.ts'), 'utf8');
expect(guard).toContain('__PATCHSTACK_SITE_UUID__');
expect(guard).not.toContain('drop()');
});
});
Loading
Loading