@patchstack/connect 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ // Preloaded into the verification child by `protect --check --runtime`, before the app's own entry.
2
+ //
3
+ // It answers what the parent cannot see for itself: which HTTP listeners does starting this app produce,
4
+ // on what address, and did this app attempt to start another process. It also contains the run: a
5
+ // probeable listener is moved to loopback, an unsupported listener is stopped from binding, and another
6
+ // process is refused before launch. The child exists only because the verifier started it, so a server
7
+ // or process it leaves behind would be the verifier's doing.
8
+ //
9
+ // The scope of that containment is one process. Every attempt to start another process is refused. A
10
+ // child can replace its environment, create a process group in its launch options, or daemonize after it
11
+ // starts; the parent cannot establish from the call that its reporter and group kill will still reach
12
+ // the resulting process. The runtime check already has to decline when another process is involved, so
13
+ // it does not start one it cannot safely leave behind.
14
+ //
15
+ // A worker THREAD does load this file, and its listeners are moved to loopback like any other, but a
16
+ // worker has no `process.send`, so its reports cannot leave it. Containment holds and observation does
17
+ // not — which would make a pass drawn from the main thread's listeners a pass for part of an app. So
18
+ // worker creation is reported and the run declines. A worker given a replacement `env` does not load
19
+ // this file at all (a worker's `execArgv` does not carry a preload, so `NODE_OPTIONS` is the only route
20
+ // in), and is refused rather than allowed to bind outside the reporter.
21
+ //
22
+ // What is reported is bounded and names no content. An argument to one of these calls can be a whole
23
+ // shell command line, and a startup command commonly carries a token or a password; a verifier that
24
+ // copied one into its own diagnostics would put a secret in terminal or CI output that the app never
25
+ // printed itself. So a launch is described by its launcher and the basename of its executable, and a
26
+ // form whose argument is a command line is described by the fact that it is one.
27
+ //
28
+ // Reported over IPC rather than stdout: the app owns stdout, and a report that has to be parsed out of
29
+ // arbitrary application output is a report that breaks the first time an app prints something similar.
30
+ 'use strict';
31
+
32
+ const net = require('node:net');
33
+ const http = require('node:http');
34
+ const https = require('node:https');
35
+ const childProcess = require('node:child_process');
36
+ const workerThreads = require('node:worker_threads');
37
+ const { basename } = require('node:path');
38
+
39
+ const LOOPBACK = '127.0.0.1';
40
+
41
+ /**
42
+ * Set once the parent says it has stopped listening.
43
+ *
44
+ * After this point a report would not be read, so a listener is refused rather than bound: a listener
45
+ * nobody is going to ask about must not exist, and the parent is about to end this process anyway.
46
+ */
47
+ let frozen = false;
48
+
49
+ function report(message) {
50
+ try {
51
+ if (typeof process.send === 'function') process.send(message);
52
+ } catch {
53
+ // The channel is the parent's to keep open. If it is gone there is nothing to tell and nothing to do
54
+ // about it, and this must not be what breaks the app it was preloaded into.
55
+ }
56
+ }
57
+
58
+ /**
59
+ * What transport a `listen()` call asks for, read from the ARGUMENTS.
60
+ *
61
+ * The call decides this, not the class of the server: an `http.Server` listening on a Unix path or a
62
+ * file descriptor is not a TCP listener, and rewriting it to one would run a different program than the
63
+ * app. So the arguments are classified first, and the class is consulted only for a TCP listen.
64
+ *
65
+ * @returns {{ kind: 'tcp' } | { kind: 'unsupported', why: string }}
66
+ */
67
+ function transportOf(args) {
68
+ const [first] = args;
69
+
70
+ const validPort = (value) => {
71
+ if (typeof value !== 'number' && typeof value !== 'string') return false;
72
+ if (typeof value === 'string' && value.trim() === '') return false;
73
+ const number = Number(value);
74
+
75
+ return Number.isInteger(number) && number >= 0 && number <= 0xffff;
76
+ };
77
+
78
+ // Node's rule (`isPipeName` in `net`): a string is a path only when it is not a non-negative
79
+ // number. `'3000'`, `' 3000'` and `'3e3'` are all TCP port 3000 — which is also what
80
+ // `listen(process.env.PORT)` normally hands over.
81
+ if (typeof first === 'string') {
82
+ if (Number(first) < 0 || Number.isNaN(Number(first))) return { kind: 'unsupported', why: 'a Unix socket path' };
83
+
84
+ return validPort(first) ? { kind: 'tcp' } : { kind: 'unsupported', why: 'an invalid TCP port' };
85
+ }
86
+ if (typeof first === 'number') return validPort(first) ? { kind: 'tcp' } : { kind: 'unsupported', why: 'an invalid TCP port' };
87
+
88
+ if (first && typeof first === 'object') {
89
+ // This is Node's precedence: a brought handle, then a usable fd, then a port, then a path. Reading
90
+ // `path` first would refuse `{ path, port }` even though Node uses its port; reading `port` before a
91
+ // handle would rewrite an options object whose connection Node takes from somewhere else.
92
+ if (first._handle || first.handle) return { kind: 'unsupported', why: 'a handle' };
93
+ if (typeof first.fd === 'number' && first.fd >= 0) return { kind: 'unsupported', why: 'a file descriptor' };
94
+
95
+ const hasPort = 'port' in first;
96
+ const port = (hasPort && (first.port === undefined || first.port === null)) ? 0 : first.port;
97
+ if (typeof port === 'number' || typeof port === 'string') {
98
+ return validPort(port) ? { kind: 'tcp' } : { kind: 'unsupported', why: 'an invalid TCP port' };
99
+ }
100
+ if (typeof first.path === 'string' && (Number(first.path) < 0 || Number.isNaN(Number(first.path)))) {
101
+ return { kind: 'unsupported', why: 'a Unix socket path' };
102
+ }
103
+
104
+ return { kind: 'unsupported', why: 'invalid listen options' };
105
+ }
106
+ if (first === undefined || first === null || typeof first === 'function') return { kind: 'tcp' }; // `listen()` / `listen(null)` / `listen(cb)`
107
+
108
+ return { kind: 'unsupported', why: `an argument of type ${typeof first}` };
109
+ }
110
+
111
+ /**
112
+ * The scheme a server serves, or null when it is not one this can probe.
113
+ *
114
+ * HTTP/2 is not one: a cleartext `Http2Server` is a `net.Server` and not an `http.Server`, and a secure
115
+ * one extends `tls.Server` rather than `https.Server`, so neither matches — which is the right answer,
116
+ * because an HTTP/1 request is not how you ask an h2 server anything.
117
+ */
118
+ function schemeOf(server) {
119
+ if (server instanceof https.Server) return 'https';
120
+ if (server instanceof http.Server) return 'http';
121
+
122
+ return null;
123
+ }
124
+
125
+ /**
126
+ * Rewrite a TCP listen to an ephemeral loopback port, preserving the overload.
127
+ *
128
+ * Two changes, both load-bearing. The PORT becomes ephemeral because the app's own port may already be
129
+ * held by whatever the developer is running, and a verification that fails on EADDRINUSE says nothing
130
+ * about wiring. The HOST becomes loopback because a verification must not put the app on an address
131
+ * other machines can reach; the parent checks the address that was actually bound and refuses anything
132
+ * else, so this rewrite is stated rather than trusted.
133
+ */
134
+ function onLoopback(args) {
135
+ const [first, ...rest] = args;
136
+
137
+ if (first === undefined || typeof first === 'function') return [{ port: 0, host: LOOPBACK }, ...args];
138
+ if (typeof first === 'number' || typeof first === 'string') {
139
+ // `listen(port[, host][, backlog][, cb])`: the host, if any, is replaced; anything after it that is
140
+ // not a host is kept.
141
+ const tail = rest.filter((a) => typeof a !== 'string');
142
+
143
+ return [{ port: 0, host: LOOPBACK }, ...tail];
144
+ }
145
+
146
+ return [{ ...first, port: 0, host: LOOPBACK }, ...rest];
147
+ }
148
+
149
+ const originalListen = net.Server.prototype.listen;
150
+ let pendingListens = 0;
151
+ let freezeAcknowledged = false;
152
+
153
+ /** A freeze is acknowledged only after every listen already admitted has either reported or failed. */
154
+ function acknowledgeFreeze() {
155
+ if (!frozen || pendingListens !== 0 || freezeAcknowledged) return;
156
+ freezeAcknowledged = true;
157
+ report({ patchstackVerify: 'frozen' });
158
+ }
159
+
160
+ net.Server.prototype.listen = function patchstackVerifyListen(...args) {
161
+ const transport = transportOf(args);
162
+ const scheme = schemeOf(this);
163
+
164
+ if (transport.kind === 'unsupported' || scheme === null || frozen) {
165
+ const why = frozen
166
+ ? 'opens after the verification stopped reading reports'
167
+ : transport.kind === 'unsupported'
168
+ ? `listens on ${transport.why}`
169
+ : `is not an HTTP or HTTPS server (${this?.constructor?.name ?? 'unknown'})`;
170
+ report({ patchstackVerify: 'unsupported-listener', why });
171
+
172
+ // Not bound. The verification cannot speak for this listener, and leaving it to open a port while a
173
+ // verifier holds the process would be the verifier opening it. The error is the app's to see, so it
174
+ // arrives the way a refused bind would.
175
+ const error = Object.assign(new Error(`Patchstack runtime verification does not support a server that ${why}`), {
176
+ code: 'EPERM',
177
+ syscall: 'listen',
178
+ });
179
+ setImmediate(() => this.emit('error', error));
180
+
181
+ return this;
182
+ }
183
+
184
+ pendingListens++;
185
+ const onListening = () => {
186
+ const address = this.address();
187
+ if (address && typeof address === 'object') {
188
+ report({ patchstackVerify: 'listener', scheme, host: address.address, port: address.port });
189
+ }
190
+ pendingListens--;
191
+ acknowledgeFreeze();
192
+ };
193
+ this.once('listening', onListening);
194
+
195
+ let result;
196
+ try {
197
+ result = originalListen.apply(this, onLoopback(args));
198
+ } catch (error) {
199
+ this.removeListener('listening', onListening);
200
+ pendingListens--;
201
+ acknowledgeFreeze();
202
+ throw error;
203
+ }
204
+
205
+ return result;
206
+ };
207
+
208
+ /** Bounded, so an unbounded argument cannot become an unbounded diagnostic. */
209
+ const WHAT_LIMIT = 80;
210
+
211
+ /** A launched program named by its executable alone — never by its arguments, which carry the secrets. */
212
+ function executableName(command) {
213
+ if (typeof command !== 'string') return `a ${typeof command}`;
214
+ const name = basename(command).replace(/\.exe$/i, '');
215
+
216
+ return name === '' ? 'a program' : name.slice(0, WHAT_LIMIT);
217
+ }
218
+
219
+ const isNodeExecutable = (command) =>
220
+ typeof command === 'string' && (command === process.execPath || basename(command).replace(/\.exe$/i, '') === 'node');
221
+
222
+ /**
223
+ * Whether the call asked for a shell, which makes its command argument a command LINE.
224
+ *
225
+ * `exec` and `execSync` always do. Every other helper does it through `shell` in an options object, and
226
+ * `exec` reaches `execFile` that way internally — so the option is looked for wherever it sits in the
227
+ * argument list rather than at a fixed position. A command line is the argument most likely to carry a
228
+ * credential and the one least possible to judge, so it is never described by its content.
229
+ */
230
+ const shellRequested = (args) =>
231
+ args.some((arg) => arg !== null && typeof arg === 'object' && !Array.isArray(arg) && Boolean(arg.shell));
232
+
233
+ /** Whether a replacement environment preserves the exact reporter options this process received. */
234
+ const carriesReporter = (env) => {
235
+ try {
236
+ return (
237
+ env !== null &&
238
+ typeof env === 'object' &&
239
+ typeof process.env.NODE_OPTIONS === 'string' &&
240
+ env.NODE_OPTIONS === process.env.NODE_OPTIONS
241
+ );
242
+ } catch {
243
+ return false;
244
+ }
245
+ };
246
+
247
+ /** Refused the way an impossible bind is: reported, and handed to the app as its own failed call. */
248
+ function refuse(what, why) {
249
+ throw Object.assign(new Error(`Patchstack runtime verification does not start ${what} that ${why}`), { code: 'EPERM' });
250
+ }
251
+
252
+ /**
253
+ * Report and refuse every process the app attempts to start.
254
+ *
255
+ * Starting another process is outside the check's one-process contract. The launch is reported and
256
+ * refused before delegation. This is intentionally broader than inspecting `detached` and `env`: a
257
+ * program that starts normally may create its own session after launch, beyond the process-group cleanup
258
+ * the parent relies on.
259
+ *
260
+ * Two layers, because the exported helpers are not the only way in. They are wrapped for what they can
261
+ * say — which helper was called, and whether the command is this runtime — and `ChildProcess.prototype
262
+ * .spawn` is wrapped underneath them because every asynchronous launch goes through it however it was
263
+ * reached: `new ChildProcess().spawn(…)` directly, `cluster.fork()`, or a helper captured as a value.
264
+ * Because the exported wrapper refuses before it delegates, a helper produces one report. The low-level
265
+ * wrapper covers calls that bypass the helpers entirely; the synchronous helpers do not pass through it.
266
+ */
267
+ const PROCESS_REFUSAL = 'leaves the one-process scope of this runtime verification';
268
+
269
+ for (const name of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) {
270
+ if (typeof childProcess[name] !== 'function') continue;
271
+ childProcess[name] = function patchstackVerifyLaunch(...args) {
272
+ // `fork` always runs this runtime. For the rest the first argument is the command, unless a shell
273
+ // was asked for, in which case it is a whole command line and cannot be judged.
274
+ const shellForm = name === 'exec' || name === 'execSync' || shellRequested(args);
275
+ const command = name === 'fork' ? process.execPath : args[0];
276
+ report({
277
+ patchstackVerify: 'process-created',
278
+ via: name,
279
+ what: shellForm ? 'a shell command line' : executableName(command),
280
+ node: !shellForm && isNodeExecutable(command),
281
+ escape: PROCESS_REFUSAL,
282
+ });
283
+ refuse('a process', PROCESS_REFUSAL);
284
+ };
285
+ }
286
+
287
+ const ChildProcessClass = childProcess.ChildProcess;
288
+ if (typeof ChildProcessClass === 'function' && typeof ChildProcessClass.prototype.spawn === 'function') {
289
+ ChildProcessClass.prototype.spawn = function patchstackVerifyChildSpawn(...args) {
290
+ const options = args[0] && typeof args[0] === 'object' ? args[0] : {};
291
+ report({
292
+ patchstackVerify: 'process-created',
293
+ via: 'ChildProcess.spawn',
294
+ what: executableName(options.file),
295
+ node: isNodeExecutable(options.file),
296
+ escape: PROCESS_REFUSAL,
297
+ });
298
+ refuse('a process', PROCESS_REFUSAL);
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Report every worker thread the app starts.
304
+ *
305
+ * Reported rather than screened, for the reason at the top of this file: a worker loads this and its
306
+ * listeners are contained, but it has no channel back, so a listener it opens can be neither counted
307
+ * nor asked. The name is the worker's file, or the fact that its source was given inline — the source
308
+ * itself is the app's, and not this file's to copy anywhere.
309
+ */
310
+ const OriginalWorker = workerThreads.Worker;
311
+ if (typeof OriginalWorker === 'function') {
312
+ const workerName = (filename) => {
313
+ try {
314
+ if (typeof filename === 'string') return basename(filename).slice(0, WHAT_LIMIT) || 'a worker';
315
+ if (filename && typeof filename.pathname === 'string') return basename(filename.pathname).slice(0, WHAT_LIMIT) || 'a worker';
316
+ } catch {
317
+ // A `filename` of some other shape is still a worker, and saying so is the whole report.
318
+ }
319
+
320
+ return 'a worker';
321
+ };
322
+
323
+ // Sharing the parent's environment is the default and is also stated explicitly with this symbol;
324
+ // either way the reporter is carried in.
325
+ const shared = workerThreads.SHARE_ENV;
326
+ const inherits = (options) => options === null || typeof options !== 'object' || options.env === undefined || options.env === shared;
327
+
328
+ workerThreads.Worker = class Worker extends OriginalWorker {
329
+ constructor(filename, options) {
330
+ const escape = inherits(options) || carriesReporter(options.env) ? null : 'replaces the environment that carries the listener reporter';
331
+ report({
332
+ patchstackVerify: 'worker-created',
333
+ what: options && options.eval === true ? 'inline source' : workerName(filename),
334
+ ...(escape === null ? {} : { escape }),
335
+ });
336
+ if (escape !== null) refuse('a worker thread', escape);
337
+ super(filename, options);
338
+ }
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Answer the parent's freeze request, and stop letting anything new open.
344
+ *
345
+ * The acknowledgement is what makes the parent's final read safe. IPC is ordered, so by the time this
346
+ * reply arrives every report sent before it has already been delivered — including one from a listener
347
+ * that opened while the parent was still reading the last response. Nothing may bind after this point,
348
+ * because nothing would be read.
349
+ */
350
+ process.on('message', (message) => {
351
+ if (message === null || typeof message !== 'object' || message.patchstackVerify !== 'freeze') return;
352
+ frozen = true;
353
+ acknowledgeFreeze();
354
+ });
355
+
356
+ // The channel is the parent's to hold open. Listening on it must not be what keeps an app alive that
357
+ // would otherwise have exited on its own.
358
+ if (process.channel && typeof process.channel.unref === 'function') process.channel.unref();
359
+
360
+ report({ patchstackVerify: 'ready', pid: process.pid });
@@ -1,5 +1,5 @@
1
1
  // Patchstack runtime guard for CommonJS Express apps. Managed by `patchstack-connect protect`.
2
- const { createProtection } = require("@patchstack/connect/protect");
2
+ const { createProtection, sentinelAnswer, VERIFY_HEADER } = require("@patchstack/connect/protect");
3
3
  // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
4
4
  // throw here is the app failing to boot rather than protection failing open — and a rules file can be
5
5
  // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
@@ -78,19 +78,37 @@ function patchstackMiddleware(req, res, next) {
78
78
  passedOn = true;
79
79
  next(err);
80
80
  };
81
- getProtection().then(
82
- (active) => active.express()(req, res, carryOn),
83
- (err) => {
81
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
82
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
83
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
84
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
85
+ // there is nothing to answer and the request is screened as normal.
86
+ const screen = () => {
87
+ getProtection().then(
88
+ (active) => active.express()(req, res, carryOn),
89
+ (err) => {
90
+ psStepAside(err);
91
+ carryOn();
92
+ },
93
+ ).catch((err) => {
94
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
95
+ // request is carried on only if it never was — an error here must not take the process down and
96
+ // must not answer twice.
84
97
  psStepAside(err);
85
98
  carryOn();
86
- },
87
- ).catch((err) => {
88
- // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
89
- // request is carried on only if it never was — an error here must not take the process down and
90
- // must not answer twice.
91
- psStepAside(err);
92
- carryOn();
93
- });
99
+ });
100
+ };
101
+
102
+ sentinelAnswer(req.headers?.[VERIFY_HEADER]).then((answered) => {
103
+ if (answered) {
104
+ res.statusCode = 200;
105
+ res.setHeader("content-type", "text/plain");
106
+ res.end(answered);
107
+
108
+ return;
109
+ }
110
+ screen();
111
+ }, screen);
94
112
  }
95
113
 
96
114
  module.exports = { patchstackMiddleware };
@@ -1,6 +1,6 @@
1
1
  // Patchstack runtime guard for ESM Express apps. Managed by `patchstack-connect protect`.
2
2
  import { readFileSync } from "node:fs";
3
- import { createProtection } from "@patchstack/connect/protect";
3
+ import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
4
4
 
5
5
  // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
6
6
  // throw here is the app failing to boot rather than protection failing open — and a rules file can be
@@ -79,17 +79,35 @@ export function patchstackMiddleware(req, res, next) {
79
79
  passedOn = true;
80
80
  next(err);
81
81
  };
82
- getProtection().then(
83
- (active) => active.express()(req, res, carryOn),
84
- (err) => {
82
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
83
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
84
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
85
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
86
+ // there is nothing to answer and the request is screened as normal.
87
+ const screen = () => {
88
+ getProtection().then(
89
+ (active) => active.express()(req, res, carryOn),
90
+ (err) => {
91
+ psStepAside(err);
92
+ carryOn();
93
+ },
94
+ ).catch((err) => {
95
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
96
+ // request is carried on only if it never was — an error here must not take the process down and
97
+ // must not answer twice.
85
98
  psStepAside(err);
86
99
  carryOn();
87
- },
88
- ).catch((err) => {
89
- // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
90
- // request is carried on only if it never was — an error here must not take the process down and
91
- // must not answer twice.
92
- psStepAside(err);
93
- carryOn();
94
- });
100
+ });
101
+ };
102
+
103
+ sentinelAnswer(req.headers?.[VERIFY_HEADER]).then((answered) => {
104
+ if (answered) {
105
+ res.statusCode = 200;
106
+ res.setHeader("content-type", "text/plain");
107
+ res.end(answered);
108
+
109
+ return;
110
+ }
111
+ screen();
112
+ }, screen);
95
113
  }
@@ -1,6 +1,6 @@
1
1
  // Patchstack runtime guard for Express. Managed by `patchstack-connect protect`.
2
2
  // Register after body parsing and before routes: app.use(patchstackMiddleware).
3
- import { createProtection } from "@patchstack/connect/protect";
3
+ import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
4
4
  import fallbackRules from "./rules.json";
5
5
 
6
6
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
@@ -75,17 +75,40 @@ export function patchstackMiddleware(req: unknown, res: unknown, next: (err?: un
75
75
  passedOn = true;
76
76
  next(err);
77
77
  };
78
- getProtection().then(
79
- (protection) => (protection.express() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
80
- (err) => {
78
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered here,
79
+ // before the protection is asked for and before the request is passed on, so no handler of the app's
80
+ // ever sees a verification request. The answer is derived from a challenge the verifying process mints
81
+ // per run, which rules out an app matching it by accident; without a challenge in the environment
82
+ // there is nothing to answer and the request is screened as normal.
83
+ const screen = () => {
84
+ getProtection().then(
85
+ (protection) => (protection.express() as (a: unknown, b: unknown, c: (e?: unknown) => void) => void)(req, res, carryOn),
86
+ (err) => {
87
+ psStepAside(err);
88
+ carryOn();
89
+ },
90
+ ).catch((err) => {
91
+ // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
92
+ // request is carried on only if it never was — an error here must not take the process down and
93
+ // must not answer twice.
81
94
  psStepAside(err);
82
95
  carryOn();
83
- },
84
- ).catch((err) => {
85
- // Not a failed build: the guard, or the app's own chain, threw after this point. Reported, and the
86
- // request is carried on only if it never was — an error here must not take the process down and
87
- // must not answer twice.
88
- psStepAside(err);
89
- carryOn();
90
- });
96
+ });
97
+ };
98
+
99
+ // The two shapes this seam touches, named rather than asserted wholesale: a request whose headers it
100
+ // reads, and a response it answers on.
101
+ const inbound = req as { headers?: Record<string, unknown> };
102
+ const outbound = res as { statusCode: number; setHeader(name: string, value: string): void; end(body?: string): void };
103
+
104
+ sentinelAnswer(inbound.headers?.[VERIFY_HEADER]).then((answered) => {
105
+ if (answered) {
106
+ outbound.statusCode = 200;
107
+ outbound.setHeader("content-type", "text/plain");
108
+ outbound.end(answered);
109
+
110
+ return;
111
+ }
112
+ screen();
113
+ }, screen);
91
114
  }
@@ -1,6 +1,6 @@
1
1
  // Patchstack runtime guard for CommonJS Fastify apps. Managed by `patchstack-connect protect`.
2
2
  // Register once: app.register(patchstackFastify)
3
- const { createProtection } = require("@patchstack/connect/protect");
3
+ const { createProtection, sentinelAnswer, VERIFY_HEADER } = require("@patchstack/connect/protect");
4
4
  // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
5
5
  // throw here is the app failing to boot rather than protection failing open — and a rules file can be
6
6
  // absent for ordinary reasons: a bundler that copied no JSON, a partial deploy, a half-written edit.
@@ -73,6 +73,19 @@ async function patchstackFastify(fastify) {
73
73
  await getProtection().catch(psStepAside);
74
74
 
75
75
  fastify.addHook("preHandler", async (request, reply) => {
76
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered before
77
+ // the route and before this request's screening — not before the protection was ever asked for,
78
+ // which registration above already did, deliberately, so startup egress is screened. The answer is
79
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
80
+ // by accident; without a challenge in the environment this is a header read.
81
+ const answered = await sentinelAnswer(request.headers?.[VERIFY_HEADER]);
82
+ if (answered) {
83
+ reply.code(200);
84
+ reply.header("content-type", "text/plain");
85
+ reply.send(answered);
86
+
87
+ return reply;
88
+ }
76
89
  const protection = await getProtection().catch(psStepAside);
77
90
  if (!protection) return; // the route answers this request, unscreened
78
91
  const guard = protection.fetchGuard();
@@ -1,7 +1,7 @@
1
1
  // Patchstack runtime guard for ESM Fastify apps. Managed by `patchstack-connect protect`.
2
2
  // Register once: app.register(patchstackFastify)
3
3
  import { readFileSync } from "node:fs";
4
- import { createProtection } from "@patchstack/connect/protect";
4
+ import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
5
5
 
6
6
  // The fallback bundle is optional at RUNTIME. This file is imported on the app's own module path, so a
7
7
  // throw here is the app failing to boot rather than protection failing open — and a rules file can be
@@ -74,6 +74,19 @@ export async function patchstackFastify(fastify) {
74
74
  await getProtection().catch(psStepAside);
75
75
 
76
76
  fastify.addHook("preHandler", async (request, reply) => {
77
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered before
78
+ // the route and before this request's screening — not before the protection was ever asked for,
79
+ // which registration above already did, deliberately, so startup egress is screened. The answer is
80
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
81
+ // by accident; without a challenge in the environment this is a header read.
82
+ const answered = await sentinelAnswer(request.headers?.[VERIFY_HEADER]);
83
+ if (answered) {
84
+ reply.code(200);
85
+ reply.header("content-type", "text/plain");
86
+ reply.send(answered);
87
+
88
+ return reply;
89
+ }
77
90
  const protection = await getProtection().catch(psStepAside);
78
91
  if (!protection) return; // the route answers this request, unscreened
79
92
  const guard = protection.fetchGuard();
@@ -2,7 +2,7 @@
2
2
  // Register it once (`app.register(patchstackFastify)`); it adds a preHandler hook that runs the
3
3
  // request-phase WAF (+ egress SSRF) on every request. Fastify's request/reply aren't Web-Fetch
4
4
  // shaped, so we reconstruct a Request from the parsed fastify request and run the fetch guard.
5
- import { createProtection } from "@patchstack/connect/protect";
5
+ import { createProtection, sentinelAnswer, VERIFY_HEADER } from "@patchstack/connect/protect";
6
6
  import fallbackRules from "./rules.json";
7
7
 
8
8
  const PS_SITE_UUID = "__PATCHSTACK_SITE_UUID__";
@@ -72,6 +72,19 @@ export async function patchstackFastify(fastify: any) {
72
72
  await getProtection().catch(psStepAside);
73
73
 
74
74
  fastify.addHook("preHandler", async (request: any, reply: any) => {
75
+ // `protect --check --runtime` asks whether a request actually reaches this seam. Answered before
76
+ // the route and before this request's screening — not before the protection was ever asked for,
77
+ // which registration above already did, deliberately, so startup egress is screened. The answer is
78
+ // derived from a challenge the verifying process mints per run, which rules out an app matching it
79
+ // by accident; without a challenge in the environment this is a header read.
80
+ const answered = await sentinelAnswer(request.headers?.[VERIFY_HEADER]);
81
+ if (answered) {
82
+ reply.code(200);
83
+ reply.header("content-type", "text/plain");
84
+ reply.send(answered);
85
+
86
+ return reply;
87
+ }
75
88
  const protection = await getProtection().catch(psStepAside);
76
89
  if (!protection) return; // the route answers this request, unscreened
77
90
  const guard = protection.fetchGuard();