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
23 changes: 23 additions & 0 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,29 @@ export async function createProtection(options = {}) {
// Resolved once and threaded through ctx: reading it is a filesystem hit on
// runtimes that have one, and refreshes should not repeat it.
const pulseAuth = await resolvePulseAuth(options);
// A site UUID with no credential behind it, said out loud ONCE at boot.
//
// Resolution reads `.patchstackrc.json`, so it needs a filesystem and a working directory. The
// runtimes this guard is built for do not all have one: on a Worker or an edge function the file is
// absent and only `PATCHSTACK_PULSE_AUTH` / `PATCHSTACK_API_KEY` can carry the credential.
//
// Unauthenticated rule fetches are accepted today, so the failure is currently invisible — and it
// stays invisible once they are not, because a rejected fetch fails open onto the cached or bundled
// bundle. The guard then screens every request, reports healthy, and never receives another rule.
// That silence is the whole problem: an app protected by rules frozen at install time looks exactly
// like an app protected by current ones.
//
// A warning, not a throw. Booting is protection; refusing to boot over a missing credential would
// trade a stale rule set for no rule set at all.
if (options.siteUuid && !pulseAuth) {
const message =
'Patchstack: no API credential resolved for site ' +
options.siteUuid +
'. Rule updates may be rejected and this guard would keep running on its cached rules. ' +
'Set PATCHSTACK_API_KEY (or pass { pulseAuth }) — required on runtimes without a filesystem.';
onError?.(new Error(message));
console.warn(message);
}
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs, pulseAuth });
// OPT-IN, deliberately. Two reasons, and the first is not about privacy: switching it on adds an
// outbound POST to every guard that has a site UUID, which is a change in what an installed app does
Expand Down
132 changes: 132 additions & 0 deletions tests/protect/credential-visibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createProtection } from '../../src/protect/runtime.js';

/**
* A site UUID with no credential behind it must be AUDIBLE at boot.
*
* The credential is read from `.patchstackrc.json`, which needs a filesystem and a working directory —
* neither of which exists on a Worker or an edge function, where only an environment variable can carry
* it. Nothing about that failure shows up in traffic: the rules fetch is rejected, the guard falls open
* onto its cached or bundled rules, and it goes on screening every request. An app frozen at the rule
* set it installed with is indistinguishable from a current one, from the outside.
*
* So the guarantee under test is a diagnostic, and the assertions are about what the operator can see:
* the warning fires when a credential is missing, does NOT fire when one resolves, and never costs the
* app its protection either way.
*/
const NO_CREDENTIAL_ENV = ['PATCHSTACK_API_KEY', 'PATCHSTACK_PULSE_AUTH'] as const;

/** A directory with no `.patchstackrc.json` in it, standing in for a runtime with nothing to read. */
const emptyCwd = () => mkdtempSync(join(tmpdir(), 'ps-no-credential-'));

/** Rules passed inline so no fetch is needed: this file is about the credential, not the transport. */
const RULES = {
firewall: [
{
id: 'r1',
title: 'blocks a marker in the query',
rule_v2: [{ parameter: 'get.q', match: { type: 'contains', value: 'boom' } }],
},
],
};

const app = async () => new Response('ok', { status: 200 });

describe('a site UUID with no credential is reported at boot', () => {
const saved: Record<string, string | undefined> = {};

beforeEach(() => {
for (const key of NO_CREDENTIAL_ENV) {
saved[key] = process.env[key];
delete process.env[key];
}
// Setting a site UUID makes the boot attempt a rule fetch. Stubbed so these tests neither touch the
// network nor depend on it: the fetch fails, the guard falls back to the inline rules, and what is
// left under test is the credential diagnostic.
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('offline');
}),
);
});

afterEach(() => {
for (const key of NO_CREDENTIAL_ENV) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
vi.restoreAllMocks();
});

it('warns, and names what to set', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

await createProtection({ siteUuid: 'site-nocred', rules: RULES, cwd: emptyCwd() });

const said = warn.mock.calls.flat().join(' ');
expect(said, 'the site it could not authenticate').toContain('site-nocred');
// The remedy, not just the symptom: a warning that does not say what to set leaves an operator on a
// filesystem-less runtime with no next step, which is where this failure actually happens.
expect(said, 'the variable that fixes it').toContain('PATCHSTACK_API_KEY');
});

it('reports through onError too, so a host that captures logs structurally sees it', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const errors: Error[] = [];

await createProtection({
siteUuid: 'site-nocred',
rules: RULES,
cwd: emptyCwd(),
onError: (err: Error) => errors.push(err),
});

expect(errors.map((e) => e.message).join(' ')).toContain('site-nocred');
});

it('does not warn when a credential resolves', async () => {
// The control. Without it the test above passes for a warning hard-wired to `siteUuid`, which would
// fire on every correctly-configured install and train operators to ignore it.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

await createProtection({
siteUuid: 'site-nocred',
rules: RULES,
cwd: emptyCwd(),
pulseAuth: 'a-secret-40-chars-long-enough-for-this-1',
});

expect(warn.mock.calls.flat().join(' ')).not.toContain('site-nocred');
});

it('does not warn when there is no site UUID to authenticate for', async () => {
// Bundled-rules mode is a supported configuration, not a misconfiguration: there is no per-site
// lookup to authenticate, so there is nothing missing.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});

await createProtection({ rules: RULES, cwd: emptyCwd() });

expect(warn.mock.calls.flat().join(' ')).not.toMatch(/credential/i);
});

it('still protects — the warning never becomes a refusal to boot', async () => {
// The reason this is a warning and not a throw. A missing credential costs rule FRESHNESS; refusing
// to boot over it would cost protection entirely, which is strictly worse than running on stale rules.
vi.spyOn(console, 'warn').mockImplementation(() => {});
process.env.PATCHSTACK_MODE = 'block';

try {
const p = await createProtection({ siteUuid: 'site-nocred', rules: RULES, cwd: emptyCwd() });

expect((await p.fetch(app)(new Request('https://x.test/?q=boom'))).status).toBe(403);
expect((await p.fetch(app)(new Request('https://x.test/?q=fine'))).status).toBe(200);
} finally {
delete process.env.PATCHSTACK_MODE;
}
});

});
Loading