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
39 changes: 31 additions & 8 deletions src/protect/engine/pulse-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export class PulseRuleClient {
#etag;
#pulseAuth;

constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth } = {}) {
#reportsDetections;

constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs, pulseAuth, reportsDetections } = {}) {
// Bounded so app STARTUP can't hang on a slow API: hosted platforms fail a deploy whose health
// check is slow, and we always have a cache/bundled fallback to boot from.
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30_000;
Expand All @@ -37,6 +39,16 @@ export class PulseRuleClient {
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
this.#etag = etag ?? null;
this.#pulseAuth = pulseAuth ?? null;
// Whether this guard reports detections, declared on a request it already makes.
//
// Detections are only sent when a rule fires, so silence at the server means one of three things —
// nothing matched, reporting is off, or reports are not arriving — and nothing distinguishes them.
// Saying "reporting is on" on the rules fetch does, without a new outbound path or any request data:
// the fetch is already periodic, already authenticated, and already carries this site's identity.
//
// A capability, not a timestamp: the server records when IT saw this, because a client clock is a
// value from outside and "alive as of" is exactly the claim a stale or wrong clock would fake.
this.#reportsDetections = reportsDetections === true;
if (!this.#siteUuid) {
throw new Error('Patchstack site UUID is required. Pass { siteUuid } or set PATCHSTACK_SITE_UUID.');
}
Expand All @@ -52,13 +64,24 @@ export class PulseRuleClient {
// Unauthenticated when no credential resolved, or when the exchange
// fails — the server still accepts the UUID, and protection must never
// hinge on getting a token.
const headers = {
Accept: 'application/json',
...(await pulseAuthHeader(
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
fetch,
)),
};
const auth = await pulseAuthHeader(
{ pulseAuth: this.#pulseAuth, endpoint: this.#baseUrl, timeoutMs: this.#timeoutMs },
fetch,
);
const headers = { Accept: 'application/json', ...auth };
// Claimed only on an authenticated request. The rules endpoint still accepts a bare UUID, so on that
// path this header would be an assertion anyone holding the UUID could make — and it asserts the
// reassuring thing: that reporting is on. A dashboard would then say a site is covered because a
// stranger said so.
//
// Fetching rules must never hinge on getting a token (protection comes first), but CLAIMING a
// capability may: an unauthenticated request is one whose statements about this site carry no weight.
// This check only removes the ACCIDENTAL case. The forgeable one is not the client's to prevent, so
// anything acting on this header has to require a verified token itself before believing it — a
// client-side gate is a courtesy, never the guarantee.
if (this.#reportsDetections && typeof auth.Authorization === 'string') {
headers['X-Patchstack-Detections'] = 'enabled';
}
if (this.#etag) headers['If-None-Match'] = this.#etag;
const response = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(this.#timeoutMs) });

Expand Down
2 changes: 1 addition & 1 deletion src/protect/rules/source.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function resolveRules(options, store, ctx = {}) {
const timeoutMs = ctx.timeoutMs;
if (options.siteUuid) {
const prior = await store.read(); // { bundle, etag } | null
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth });
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs, pulseAuth: ctx.pulseAuth, reportsDetections: options.reportDetections === true });
const res = await client.getRules();
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle, options);
if (res.success && !res.notModified) {
Expand Down
1 change: 1 addition & 0 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { renderBlockPage } from './block-page.js';
import { makeStore } from './rules/store.js';
import { resolveRules } from './rules/source.js';
import { startRefresh, makeRefreshHandler } from './rules/refresh.js';
import { createDetectionReporter } from './detections.js';
import { createFirewallLogReporter, resolveApiBase, telemetryEnabled } from './firewall-log.js';

// Supabase-tunnel guard for AI-builder apps (Lovable / TanStack Start + Supabase).
Expand Down
128 changes: 128 additions & 0 deletions tests/protect/detections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,131 @@ describe('wiring', () => {
p.stopRefresh?.();
});
});

describe('declaring the capability', () => {
it('tells the server reporting is on, on a request it already makes', async () => {
// Detections are sent only when a rule fires, so silence at the server means nothing matched, or
// reporting is off, or reports are not arriving — and nothing tells those apart. The rules fetch does,
// with a header: no new outbound path, no request data, and no client timestamp (the server records
// when IT saw this, because "alive as of" is the claim a wrong clock would fake).
const seen: Array<Record<string, string>> = [];
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (String(url).includes('token')) {
return new Response(JSON.stringify({ access_token: 'jwt-abc', expires_in: 3600 }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
}
seen.push((init?.headers ?? {}) as Record<string, string>);

return new Response(JSON.stringify({ firewall: [], whitelists: [], enforcement: 'dry-run' }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);

const p: any = await createProtection({
siteUuid: 'site-1',
pulseRulesUrl: 'https://x.test/monitor/pulse',
pulseAuth: 'the-secret-40-chars-long-ish-value-here-987',
reportDetections: true,
});

// Authenticated, so the claim carries weight and is made.
const claimed = seen.filter((h) => h['X-Patchstack-Detections'] === 'enabled');
expect(claimed.length).toBeGreaterThan(0);
for (const headers of claimed) {
expect(headers.Authorization, 'the claim only travels on an authenticated request').toContain('Bearer');
}
p.stopRefresh?.();
});

it('says nothing when reporting is off', async () => {
// The declaration has to mean something: a guard that is not reporting must not claim it is, or the
// server cannot tell a configured site from an unconfigured one — which is the whole point.
const seen: Array<Record<string, string>> = [];
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
seen.push((init?.headers ?? {}) as Record<string, string>);

return new Response(JSON.stringify({ firewall: [], whitelists: [], enforcement: 'dry-run' }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);

const p: any = await createProtection({ siteUuid: 'site-1', pulseRulesUrl: 'https://x.test/monitor/pulse' });

expect(seen.every((h) => h['X-Patchstack-Detections'] === undefined)).toBe(true);
p.stopRefresh?.();
});
});

describe('the wiring actually runs', () => {
it('posts a detection when reporting is switched on', async () => {
// The gap that let a broken build merge: every other test here either exercised the reporter directly
// or asserted that NOTHING is posted when the feature is off. Neither enters the branch that builds the
// reporter, so an unresolved import in it threw only for someone who turned the feature on — which,
// being opt-in, was nobody. This test is the one that fails if the wiring is broken.
const posted: string[] = [];
const fetchMock = vi.fn(async (url: string) => {
posted.push(String(url));
if (String(url).includes('/detections/')) return new Response('{}', { status: 202 });

return new Response(
JSON.stringify({
firewall: [{ id: 'r1', title: 'boom', rule_v2: [{ parameter: 'get.q', match: { type: 'contains', value: 'boom' } }] }],
whitelists: [], enforcement: 'dry-run',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
});
vi.stubGlobal('fetch', fetchMock);

const p: any = await createProtection({
siteUuid: 'site-1',
pulseRulesUrl: 'https://x.test/monitor/pulse',
reportDetections: true,
detectionFlushMs: 1,
});

await p.fetchGuard()(new Request('https://app.test/api/x?q=boom'));
p.stopRefresh?.();
await new Promise((resolve) => setTimeout(resolve, 5));

expect(posted.some((url) => url.includes('/detections/site-1'))).toBe(true);
});
});

describe('the capability claim is only made when it carries weight', () => {
it('stays silent on an unauthenticated rules fetch', async () => {
// The rules endpoint still accepts a bare UUID, so on that path this header is an assertion anyone
// holding the UUID could make — and it asserts the reassuring thing, that reporting is on. A dashboard
// would then report a site as covered because a stranger said so. Fetching rules must not hinge on a
// token; claiming a capability must.
const seen: Array<Record<string, string>> = [];
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
seen.push((init?.headers ?? {}) as Record<string, string>);

return new Response(JSON.stringify({ firewall: [], whitelists: [], enforcement: 'dry-run' }), {
status: 200, headers: { 'Content-Type': 'application/json' },
});
});
vi.stubGlobal('fetch', fetchMock);

// No credential anywhere: no `pulseAuth`, and nothing for the token exchange to find.
const p: any = await createProtection({
siteUuid: 'site-1',
pulseRulesUrl: 'https://x.test/monitor/pulse',
reportDetections: true,
});

const rulesRequests = seen.filter((h) => h.Accept === 'application/json');
expect(rulesRequests.length).toBeGreaterThan(0);
for (const headers of rulesRequests) {
expect(headers.Authorization).toBeUndefined();
expect(headers['X-Patchstack-Detections'], 'an unauthenticated request may not claim the capability')
.toBeUndefined();
}

p.stopRefresh?.();
});
});
Loading