@assembly-js/app-foundation 0.0.41 → 0.0.43
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.
- package/CHANGELOG.md +172 -0
- package/CLAUDE.md +279 -23
- package/bin/check-paint-beacon.mjs +8 -9
- package/bin/check-proxy-csrf.mjs +387 -0
- package/dist/proxy.d.ts +11 -0
- package/dist/proxy.js +669 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/fixes.json +64 -0
- package/package.json +5 -3
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails the build when the app's proxy does not behave like the sign-in proxy:
|
|
3
|
+
// it must reject a state-changing request that lacks the CSRF header, forward
|
|
4
|
+
// one that carries it without the proxy ever adding the header itself, leave
|
|
5
|
+
// the re-exchange route reachable, and accept the cookieless (Safari) bearer.
|
|
6
|
+
// A text check cannot tell a gate from a bypass — an update turn once satisfied
|
|
7
|
+
// "proxy.ts contains APP_CSRF_HEADER" by stamping the header on every request —
|
|
8
|
+
// so this loads the app's real proxy and sends it requests.
|
|
9
|
+
//
|
|
10
|
+
// Runs under node like the other checks, then re-executes itself under bun,
|
|
11
|
+
// which can import the app's TypeScript proxy directly. In that child
|
|
12
|
+
// `server-only` and the node SDK are stubbed and fetch is disabled: no probe
|
|
13
|
+
// carries a session token, so nothing here needs the network.
|
|
14
|
+
//
|
|
15
|
+
// Like check-crons, a broken proxy fails the build only when the current turn
|
|
16
|
+
// changed it (or with --strict, which the update acceptance passes); otherwise
|
|
17
|
+
// the findings are warnings, since there is no agent to act on them. An app
|
|
18
|
+
// still on the pre-bearer foundation only ever warns outside --strict: the
|
|
19
|
+
// platform security update is what fixes it, not an ordinary turn.
|
|
20
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { createRequire } from 'node:module';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const ROOT = process.cwd();
|
|
28
|
+
const STRICT = process.argv.includes('--strict');
|
|
29
|
+
const PROXY_FILES = ['proxy.ts', 'middleware.ts'];
|
|
30
|
+
const REEXCHANGE_ROUTE = 'app/api/assembly-reexchange/route.ts';
|
|
31
|
+
const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
32
|
+
const PRE_BEARER_MARKER = 'lib/assembly.ts';
|
|
33
|
+
const VENDORED_BEARER_MARKER = 'lib/mint-bearer.ts';
|
|
34
|
+
const RESULT_PREFIX = 'check-proxy-csrf-result ';
|
|
35
|
+
const CHILD_TIMEOUT_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
|
|
38
|
+
const PROXY_WIRING_PATHS = [...PROXY_FILES, 'app/api/assembly-reexchange/'];
|
|
39
|
+
|
|
40
|
+
// Each finding id is a fixed string with a fixed explanation; the request that
|
|
41
|
+
// produced it travels as JSON detail, so reports group by id.
|
|
42
|
+
const FINDINGS = {
|
|
43
|
+
proxy_missing: 'no proxy.ts at the app root',
|
|
44
|
+
proxy_unloadable: 'the proxy module could not be loaded',
|
|
45
|
+
proxy_threw: 'the proxy threw while handling a request',
|
|
46
|
+
proxy_skips_path: 'the proxy matcher leaves a path that serves app requests unprotected',
|
|
47
|
+
csrf_gate_missing: 'a state-changing request without the CSRF header was forwarded',
|
|
48
|
+
csrf_header_stamped: 'the proxy added the CSRF header to a request that did not carry it',
|
|
49
|
+
csrf_request_blocked: 'a state-changing request carrying the CSRF header was not forwarded',
|
|
50
|
+
csrf_header_stripped: 'the proxy removed the CSRF header from a forwarded request',
|
|
51
|
+
server_action_blocked: 'a Next server action was not forwarded',
|
|
52
|
+
reexchange_not_excluded: 'the proxy matcher does not exclude the re-exchange route',
|
|
53
|
+
reexchange_blocked_by_proxy: 'the proxy does not pass a re-exchange request through with its CSRF header',
|
|
54
|
+
reexchange_route_missing: `${REEXCHANGE_ROUTE} is missing`,
|
|
55
|
+
reexchange_csrf_missing: 'the re-exchange route accepts a request without the CSRF header',
|
|
56
|
+
reexchange_always_forbidden: 'the re-exchange route rejects a request that carries the CSRF header',
|
|
57
|
+
cookieless_fallback_missing: 'a bearer sent in the client-bearer header was not forwarded to the render',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (process.env.ASSEMBLY_PROXY_CHECK_CHILD === '1') {
|
|
61
|
+
await runProbes();
|
|
62
|
+
} else {
|
|
63
|
+
runCheck();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- parent (node) ---------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
function runCheck() {
|
|
69
|
+
const proxyFile = PROXY_FILES.find((f) => existsSync(join(ROOT, f)));
|
|
70
|
+
// An app still on the pre-bearer foundation (lib/assembly.ts without
|
|
71
|
+
// lib/mint-bearer.ts, the same markers check-token-usage reads, and no
|
|
72
|
+
// package proxy) cannot pass this check by an ordinary turn's edits: the platform
|
|
73
|
+
// security update is what moves it. Its findings stay warnings that say so,
|
|
74
|
+
// unless --strict (the update's own acceptance) asks for a verdict.
|
|
75
|
+
const usesPackageProxy =
|
|
76
|
+
Boolean(proxyFile) && /createAssemblyProxy\s*\(/.test(readFileSync(join(ROOT, proxyFile), 'utf8'));
|
|
77
|
+
const preBearer =
|
|
78
|
+
existsSync(join(ROOT, PRE_BEARER_MARKER)) &&
|
|
79
|
+
!existsSync(join(ROOT, VENDORED_BEARER_MARKER)) &&
|
|
80
|
+
!usesPackageProxy;
|
|
81
|
+
const fatal = STRICT || (!preBearer && proxyChangedThisTurn());
|
|
82
|
+
const report = (findings) => reportFindings(findings, fatal, preBearer);
|
|
83
|
+
if (!proxyFile) {
|
|
84
|
+
report([{ id: 'proxy_missing', detail: {} }]);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const bun = findBun();
|
|
89
|
+
const child = spawnSync(bun, [fileURLToPath(import.meta.url), ...process.argv.slice(2)], {
|
|
90
|
+
cwd: ROOT,
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
timeout: CHILD_TIMEOUT_MS,
|
|
93
|
+
env: {
|
|
94
|
+
...process.env,
|
|
95
|
+
ASSEMBLY_PROXY_CHECK_CHILD: '1',
|
|
96
|
+
ASSEMBLY_PROXY_CHECK_FILE: proxyFile,
|
|
97
|
+
// The gate is dormant while the exchange is off, so probe it switched on.
|
|
98
|
+
ENABLE_APP_SESSION_TOKEN_EXCHANGE: 'true',
|
|
99
|
+
ASSEMBLY_API_KEY: '',
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
if (child.error) {
|
|
103
|
+
// No bun to load TypeScript with (e.g. a deploy builder): nothing to prove
|
|
104
|
+
// here, and the turn that wrote the proxy already ran this in the sandbox.
|
|
105
|
+
const line = `check-proxy-csrf: skipped, bun is not available (${child.error.code ?? 'spawn error'})`;
|
|
106
|
+
if (fatal) {
|
|
107
|
+
console.error(line);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
console.warn(line);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const resultLine = (child.stdout ?? '')
|
|
114
|
+
.split('\n')
|
|
115
|
+
.reverse()
|
|
116
|
+
.find((l) => l.startsWith(RESULT_PREFIX));
|
|
117
|
+
if (!resultLine) {
|
|
118
|
+
const stderrTail = (child.stderr ?? '').trim().split('\n').slice(-8).join('\n');
|
|
119
|
+
report([{ id: 'proxy_unloadable', detail: { file: proxyFile, stderr: stderrTail } }]);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const { findings } = JSON.parse(resultLine.slice(RESULT_PREFIX.length));
|
|
123
|
+
if (findings.length > 0) report(findings);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function reportFindings(findings, fatal, preBearer) {
|
|
127
|
+
const out = fatal ? console.error : console.warn;
|
|
128
|
+
out('');
|
|
129
|
+
out(
|
|
130
|
+
fatal
|
|
131
|
+
? 'check-proxy-csrf: the app proxy does not enforce the sign-in CSRF gate:'
|
|
132
|
+
: 'check-proxy-csrf: the app proxy does not enforce the sign-in CSRF gate (pre-existing; this build is not failed for it):',
|
|
133
|
+
);
|
|
134
|
+
out('');
|
|
135
|
+
for (const { id, detail } of findings) {
|
|
136
|
+
out(` - ${id}: ${FINDINGS[id] ?? id} ${JSON.stringify(detail)}`);
|
|
137
|
+
}
|
|
138
|
+
out('');
|
|
139
|
+
if (preBearer && !fatal) {
|
|
140
|
+
out('This app predates the platform sign-in proxy. The platform security update moves it onto that');
|
|
141
|
+
out('proxy; do not rewrite proxy.ts in this turn to satisfy this check.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
out('proxy.ts is the platform sign-in proxy. It must be exactly this, plus the app\'s own additions in onForward:');
|
|
145
|
+
out('');
|
|
146
|
+
out(" import { createAssemblyProxy } from '@assembly-js/app-foundation/server';");
|
|
147
|
+
out('');
|
|
148
|
+
out(' export const proxy = createAssemblyProxy({');
|
|
149
|
+
out(' // optional: onForward({ request, headers, session }) { headers.set(...) },');
|
|
150
|
+
out(' });');
|
|
151
|
+
out('');
|
|
152
|
+
out(' export const config = {');
|
|
153
|
+
out(' matcher: [');
|
|
154
|
+
out(" '/((?!_next/static(?:/|$)|_next/image(?:/|$)|api/assembly-reexchange(?:/|$)).*)',");
|
|
155
|
+
out(' ],');
|
|
156
|
+
out(' };');
|
|
157
|
+
out('');
|
|
158
|
+
out('Never set or delete the CSRF header in the proxy, and never write your own gate.');
|
|
159
|
+
if (fatal) process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function findBun() {
|
|
163
|
+
const execPath = process.env.npm_execpath ?? '';
|
|
164
|
+
if (/\bbun(\.exe)?$/.test(execPath)) return execPath;
|
|
165
|
+
const installed = join(process.env.BUN_INSTALL ?? join(homedir(), '.bun'), 'bin', 'bun');
|
|
166
|
+
return existsSync(installed) ? installed : 'bun';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function proxyChangedThisTurn() {
|
|
170
|
+
const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
|
|
171
|
+
if (!baseline) return false;
|
|
172
|
+
const listings = [
|
|
173
|
+
{ args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
|
|
174
|
+
// Porcelain rows are "XY path", or "XY old -> new" for a rename.
|
|
175
|
+
{ args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
|
|
176
|
+
];
|
|
177
|
+
for (const { args, parse } of listings) {
|
|
178
|
+
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
|
|
179
|
+
if (result.status !== 0) return true;
|
|
180
|
+
const changed = result.stdout.split('\n').map((line) => parse(line).trim()).filter(Boolean);
|
|
181
|
+
if (changed.some((file) => PROXY_WIRING_PATHS.some((p) => (p.endsWith('/') ? file.startsWith(p) : file === p)))) {
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// --- child (bun) -----------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
async function runProbes() {
|
|
191
|
+
const findings = [];
|
|
192
|
+
const add = (id, detail) => findings.push({ id, detail });
|
|
193
|
+
const finish = () => {
|
|
194
|
+
console.log(RESULT_PREFIX + JSON.stringify({ findings }));
|
|
195
|
+
process.exit(0);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
stubServerModules();
|
|
199
|
+
// Next's server runtime installs this global before any app module loads;
|
|
200
|
+
// its request-scoped helpers (after, headers) throw at import without it.
|
|
201
|
+
globalThis.AsyncLocalStorage ??= (await import('node:async_hooks')).AsyncLocalStorage;
|
|
202
|
+
globalThis.fetch = () => Promise.reject(new Error('network is disabled in check-proxy-csrf'));
|
|
203
|
+
|
|
204
|
+
const appRequire = createRequire(join(ROOT, 'package.json'));
|
|
205
|
+
const { NextRequest } = await import(appRequire.resolve('next/server'));
|
|
206
|
+
const { APP_CSRF_HEADER, BEARER_HEADER_NAME, CLIENT_BEARER_HEADER } = await import(
|
|
207
|
+
appRequire.resolve('@assembly-js/app-foundation/client')
|
|
208
|
+
);
|
|
209
|
+
const proxyFile = process.env.ASSEMBLY_PROXY_CHECK_FILE;
|
|
210
|
+
const mod = await import(pathToFileURL(join(ROOT, proxyFile)).href);
|
|
211
|
+
const handler = mod.proxy ?? mod.middleware ?? mod.default;
|
|
212
|
+
if (typeof handler !== 'function') {
|
|
213
|
+
add('proxy_unloadable', { file: proxyFile, reason: 'no proxy export' });
|
|
214
|
+
finish();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const origin = 'http://localhost:3000';
|
|
218
|
+
const send = async (fn, probe, method, path, headers = {}) => {
|
|
219
|
+
const request = new NextRequest(new URL(path, origin), { method, headers });
|
|
220
|
+
try {
|
|
221
|
+
return describeOutcome(await fn(request), headers);
|
|
222
|
+
} catch (err) {
|
|
223
|
+
add('proxy_threw', { probe, method, path, error: String(err?.message ?? err) });
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const apiPath = '/api/proxy-check';
|
|
228
|
+
// CSRF rides on ambient credentials, so the forged-write probes carry a
|
|
229
|
+
// cookie; a cookieless write (an inbound webhook) is allowed through.
|
|
230
|
+
const AMBIENT_COOKIE = 'assembly-proxy-check=1';
|
|
231
|
+
const CROSS_SITE_FORM = {
|
|
232
|
+
cookie: AMBIENT_COOKIE,
|
|
233
|
+
origin: 'https://attacker.example',
|
|
234
|
+
'sec-fetch-site': 'cross-site',
|
|
235
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
236
|
+
};
|
|
237
|
+
// Next only runs the proxy where config.matcher says, so a probe on any other
|
|
238
|
+
// path would pass against a gate no real request reaches.
|
|
239
|
+
const skipped = new Set();
|
|
240
|
+
for (const path of [apiPath, '/']) {
|
|
241
|
+
if (!(await proxyRunsOn(appRequire, mod.config, path))) {
|
|
242
|
+
skipped.add(path);
|
|
243
|
+
add('proxy_skips_path', { path });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const proxy = async (probe, method, path, headers) =>
|
|
247
|
+
skipped.has(path) ? null : send(handler, probe, method, path, headers);
|
|
248
|
+
|
|
249
|
+
for (const [method, path] of [
|
|
250
|
+
['POST', apiPath],
|
|
251
|
+
['PUT', apiPath],
|
|
252
|
+
['PATCH', apiPath],
|
|
253
|
+
['DELETE', apiPath],
|
|
254
|
+
['POST', '/'],
|
|
255
|
+
]) {
|
|
256
|
+
const out = await proxy('no_csrf_header', method, path, { cookie: AMBIENT_COOKIE });
|
|
257
|
+
if (out && !out.rejected) add('csrf_gate_missing', { method, path, status: out.status, variant: 'cookie' });
|
|
258
|
+
}
|
|
259
|
+
// What a cross-site form post actually sends: a gate keyed on any of these
|
|
260
|
+
// (e.g. trusting a request because it has an Origin) must still reject it.
|
|
261
|
+
for (const path of [apiPath, '/']) {
|
|
262
|
+
const out = await proxy('cross_site_form', 'POST', path, CROSS_SITE_FORM);
|
|
263
|
+
if (out && !out.rejected) add('csrf_gate_missing', { method: 'POST', path, status: out.status, variant: 'cross_site_form' });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const read = await proxy('get_without_header', 'GET', apiPath);
|
|
267
|
+
if (read?.forwarded && read.forwardedHeader(APP_CSRF_HEADER) !== null) {
|
|
268
|
+
add('csrf_header_stamped', { method: 'GET', path: apiPath });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Legitimate writes come from a signed-in browser, so they carry its cookies too.
|
|
272
|
+
const write = await proxy('with_csrf_header', 'POST', apiPath, { cookie: AMBIENT_COOKIE, [APP_CSRF_HEADER]: '1' });
|
|
273
|
+
if (write && !write.forwarded) {
|
|
274
|
+
add('csrf_request_blocked', { method: 'POST', path: apiPath, status: write.status });
|
|
275
|
+
} else if (write && write.forwardedHeader(APP_CSRF_HEADER) === null) {
|
|
276
|
+
add('csrf_header_stripped', { method: 'POST', path: apiPath });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const action = await proxy('server_action', 'POST', '/', { cookie: AMBIENT_COOKIE, 'next-action': 'proxy-check' });
|
|
280
|
+
if (action && !action.forwarded) add('server_action_blocked', { status: action.status });
|
|
281
|
+
|
|
282
|
+
// The route enforces CSRF itself; the proxy must either not run on it or
|
|
283
|
+
// pass it through untouched, else silent renewal is dead after the bearer's
|
|
284
|
+
// first hour.
|
|
285
|
+
if (await proxyRunsOn(appRequire, mod.config, REEXCHANGE_PATH)) {
|
|
286
|
+
add('reexchange_not_excluded', { path: REEXCHANGE_PATH });
|
|
287
|
+
const out = await proxy('reexchange', 'POST', REEXCHANGE_PATH, { [APP_CSRF_HEADER]: '1' });
|
|
288
|
+
if (out && (!out.forwarded || out.forwardedHeader(APP_CSRF_HEADER) === null)) {
|
|
289
|
+
add('reexchange_blocked_by_proxy', { status: out.status });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (!existsSync(join(ROOT, REEXCHANGE_ROUTE))) {
|
|
293
|
+
add('reexchange_route_missing', {});
|
|
294
|
+
} else {
|
|
295
|
+
const route = await import(pathToFileURL(join(ROOT, REEXCHANGE_ROUTE)).href);
|
|
296
|
+
if (typeof route.POST !== 'function') {
|
|
297
|
+
add('reexchange_route_missing', { reason: 'no POST export' });
|
|
298
|
+
} else {
|
|
299
|
+
const bare = await send(route.POST, 'reexchange_route', 'POST', REEXCHANGE_PATH);
|
|
300
|
+
if (bare && bare.status !== 403) add('reexchange_csrf_missing', { status: bare.status });
|
|
301
|
+
const headed = await send(route.POST, 'reexchange_route', 'POST', REEXCHANGE_PATH, {
|
|
302
|
+
[APP_CSRF_HEADER]: '1',
|
|
303
|
+
});
|
|
304
|
+
if (headed && headed.status === 403) add('reexchange_always_forbidden', { status: headed.status });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Browsers that refuse the partitioned cookie (Safari) hold the bearer in
|
|
309
|
+
// memory and send it as a header; the proxy must hand it to the render.
|
|
310
|
+
const bearer = unsignedBearer({ workspace_id: 'proxy-check', exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
311
|
+
const cookieless = await proxy('cookieless_bearer', 'GET', '/', { [CLIENT_BEARER_HEADER]: bearer });
|
|
312
|
+
if (cookieless && (!cookieless.forwarded || cookieless.forwardedHeader(BEARER_HEADER_NAME) !== bearer)) {
|
|
313
|
+
add('cookieless_fallback_missing', { status: cookieless.status });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
finish();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// What the proxy did with a request: forwarded (NextResponse.next / rewrite, or
|
|
320
|
+
// no response at all) or answered itself, and for a forward, the headers the
|
|
321
|
+
// render would see. NextResponse.next({ request: { headers } }) lists every
|
|
322
|
+
// forwarded header in x-middleware-override-headers; without that header the
|
|
323
|
+
// inbound request passes through unchanged.
|
|
324
|
+
function describeOutcome(response, inbound) {
|
|
325
|
+
if (!response) {
|
|
326
|
+
return { forwarded: true, rejected: false, status: 200, forwardedHeader: (n) => inbound[n] ?? null };
|
|
327
|
+
}
|
|
328
|
+
const forwarded =
|
|
329
|
+
response.headers.get('x-middleware-next') === '1' || response.headers.has('x-middleware-rewrite');
|
|
330
|
+
const override = response.headers.get('x-middleware-override-headers');
|
|
331
|
+
const forwardedHeader = (name) => {
|
|
332
|
+
if (override === null) return inbound[name] ?? null;
|
|
333
|
+
return override.split(',').map((h) => h.trim()).includes(name)
|
|
334
|
+
? response.headers.get(`x-middleware-request-${name}`)
|
|
335
|
+
: null;
|
|
336
|
+
};
|
|
337
|
+
return {
|
|
338
|
+
forwarded,
|
|
339
|
+
rejected: !forwarded && response.status >= 400 && response.status < 500,
|
|
340
|
+
status: response.status,
|
|
341
|
+
forwardedHeader,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function proxyRunsOn(appRequire, config, path) {
|
|
346
|
+
if (!config?.matcher) return true;
|
|
347
|
+
try {
|
|
348
|
+
const { unstable_doesMiddlewareMatch } = await import(
|
|
349
|
+
appRequire.resolve('next/experimental/testing/server')
|
|
350
|
+
);
|
|
351
|
+
return unstable_doesMiddlewareMatch({ config, url: path });
|
|
352
|
+
} catch {
|
|
353
|
+
// No matcher helper in this Next version: assume the proxy runs there.
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function unsignedBearer(claims) {
|
|
359
|
+
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
|
360
|
+
return `${b64({ alg: 'none', typ: 'JWT' })}.${b64(claims)}.proxy-check`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// `server-only` throws outside a React Server Component bundle, and the SDK
|
|
364
|
+
// would reach the network; neither has anything to do with the CSRF gate. Bun
|
|
365
|
+
// runtime plugins intercept by resolved file path, not by specifier.
|
|
366
|
+
function stubServerModules() {
|
|
367
|
+
const stub = (dir, contents) => ({
|
|
368
|
+
filter: new RegExp(`node_modules[\\\\/]${dir}[\\\\/].*\\.[cm]?js$`),
|
|
369
|
+
contents,
|
|
370
|
+
});
|
|
371
|
+
const stubs = [
|
|
372
|
+
stub('server-only', 'export {};'),
|
|
373
|
+
stub(
|
|
374
|
+
'@assembly-js[\\\\/]node-sdk',
|
|
375
|
+
"export const OpenAPI = { BASE: 'http://127.0.0.1:9' };\n" +
|
|
376
|
+
"export async function assemblyApi() { throw new Error('network is disabled in check-proxy-csrf'); }\n",
|
|
377
|
+
),
|
|
378
|
+
];
|
|
379
|
+
Bun.plugin({
|
|
380
|
+
name: 'check-proxy-csrf-stubs',
|
|
381
|
+
setup(build) {
|
|
382
|
+
for (const { filter, contents } of stubs) {
|
|
383
|
+
build.onLoad({ filter }, () => ({ contents, loader: 'js' }));
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
}
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { type ExchangedSession } from './session-exchange.js';
|
|
3
|
+
export interface AssemblyProxyForwardContext {
|
|
4
|
+
request: NextRequest;
|
|
5
|
+
headers: Headers;
|
|
6
|
+
session: ExchangedSession | null;
|
|
7
|
+
}
|
|
8
|
+
export interface AssemblyProxyOptions {
|
|
9
|
+
onForward?: (context: AssemblyProxyForwardContext) => void | Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export declare function createAssemblyProxy(options?: AssemblyProxyOptions): (request: NextRequest) => Promise<NextResponse | Response>;
|