Skip to content

Commit a4df002

Browse files
committed
fix(server): hard guardrail — never serve server-file source to the client
The dev HTTP layer used to look up .server.* / 'use server' files via the action index built at boot; on index miss it fell through to tsResponse() and returned the RAW SOURCE. That's a serious leak vector: DB credentials, scrypt routines, privileged business logic all live in these files. Replace the index lookup with an independent per-request check via isServerFile(abs), which inspects the actual file on disk. If the file qualifies (suffix OR directive), we lazily populate the action index (so stub minting still has a hash) and serve the RPC stub. The source is unreachable regardless of index state, file creation timing, or dev-reload races. Six regression tests cover: .server.ts, .server.js, 'use server' .ts, 'use server' .js, ordinary .ts negative control, and files created after boot (index race). Document this as a framework invariant in AGENTS.md.
1 parent 04fcbc0 commit a4df002

3 files changed

Lines changed: 191 additions & 6 deletions

File tree

AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,13 @@ inspired by NextJs, Lit, and Rails.
181181
calls directly — the import is rewritten into an RPC stub. The RPC wire uses
182182
**superjson**, so `Date`, `Map`, `Set`, `BigInt`, `undefined`, `URL`, `RegExp`
183183
round-trip as their real types.
184+
- **Server-file source is unreachable from the browser (framework invariant).**
185+
The HTTP layer independently re-verifies every JS/TS request against the
186+
server-file predicate (filename suffix OR `'use server'` directive) before
187+
serving bytes. A server file always responds with a generated RPC stub,
188+
never its source — this holds regardless of index state, file-system
189+
race conditions, or developer error. Enforced in `dev.js`; regression
190+
tests in `test/server-file-guardrail.test.js`.
184191

185192
---
186193

packages/server/src/dev.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import { ssrPage, ssrNotFound } from './ssr.js';
1212
import { handleApi } from './api.js';
1313
import {
1414
buildActionIndex,
15-
resolveServerModule,
1615
serveActionStub,
1716
invokeAction,
1817
matchExposedAction,
1918
matchAllAtPath,
2019
invokeExposedAction,
2120
buildPreflightResponse,
2221
withCors,
22+
isServerFile,
23+
hashFile,
2324
} from './actions.js';
2425
import { defaultLogger } from './logger.js';
2526
import { withRequest } from './context.js';
@@ -385,11 +386,26 @@ async function handleCore(req, ctx) {
385386
}
386387
}
387388
if (abs.startsWith(appDir) && (await exists(abs))) {
388-
const serverFile = resolveServerModule(state.actionIndex, path) ||
389-
// Also recognise stub requests for the .ts form.
390-
(abs !== join(appDir, path) ? resolveServerModule(state.actionIndex, '/' + relative(appDir, abs).split(sep).join('/')) : null);
391-
if (serverFile) {
392-
const stub = await serveActionStub(state.actionIndex, serverFile);
389+
// Server-file guardrail: a file is server-only if its name matches
390+
// `.server.{js,ts,mjs,mts}` OR the source starts with `'use server'`.
391+
// Such files MUST NEVER be served as source to the browser — they
392+
// contain secrets, DB queries, and privileged logic. Always return a
393+
// generated RPC stub instead.
394+
//
395+
// We re-verify via `isServerFile(abs)` on every request (not just the
396+
// action-index snapshot taken at boot). This catches files created
397+
// after boot, files that flipped their `'use server'` status, or any
398+
// race between scan completion and request — the guardrail is an
399+
// independent check, not a cache lookup.
400+
if (await isServerFile(abs)) {
401+
// Lazily ensure the index knows about this file so serveActionStub
402+
// can mint a stable hash and function list.
403+
if (!state.actionIndex.fileToHash.has(abs)) {
404+
const h = hashFile(abs);
405+
state.actionIndex.fileToHash.set(abs, h);
406+
state.actionIndex.hashToFile.set(h, abs);
407+
}
408+
const stub = await serveActionStub(state.actionIndex, abs);
393409
return new Response(stub, {
394410
headers: { 'content-type': 'application/javascript; charset=utf-8', 'cache-control': 'no-store' },
395411
});

test/server-file-guardrail.test.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/**
2+
* Server-file guardrail: the dev/prod HTTP layer MUST never serve the
3+
* source of a server-only file to the browser. A file is considered
4+
* server-only if either:
5+
* • its filename matches `.server.{js,ts,mjs,mts}`, OR
6+
* • its source begins with a literal `'use server'` directive.
7+
*
8+
* For such files, every response body must be a generated RPC stub —
9+
* never the real module source. This guardrail is the last line of
10+
* defense against an accidental source leak (DB credentials, privileged
11+
* business logic, scrypt routines, etc.).
12+
*/
13+
import { test, before, after } from 'node:test';
14+
import assert from 'node:assert/strict';
15+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
16+
import { tmpdir } from 'node:os';
17+
import { join } from 'node:path';
18+
19+
import { createRequestHandler } from '../packages/server/src/dev.js';
20+
21+
let tmpDir;
22+
23+
before(() => {
24+
tmpDir = mkdtempSync(join(tmpdir(), 'webjs-guard-'));
25+
});
26+
27+
after(() => {
28+
rmSync(tmpDir, { recursive: true, force: true });
29+
});
30+
31+
function makeApp(files) {
32+
const appDir = mkdtempSync(join(tmpDir, 'app-'));
33+
for (const [rel, body] of Object.entries(files)) {
34+
const abs = join(appDir, rel);
35+
mkdirSync(join(abs, '..'), { recursive: true });
36+
writeFileSync(abs, body);
37+
}
38+
return appDir;
39+
}
40+
41+
function assertIsStub(text) {
42+
assert.ok(
43+
text.startsWith('// webjs: generated server-action stub'),
44+
`expected RPC stub, got body starting with: ${text.slice(0, 80)}`
45+
);
46+
// Confirm the real source is NOT present by checking secrets markers
47+
// that only show up in the scaffolded fixture source.
48+
assert.ok(!/SECRET_DB_PASSWORD/.test(text),
49+
`stub leaked the source — found SECRET_DB_PASSWORD:\n${text.slice(0, 400)}`);
50+
assert.ok(!/fakePrismaClient/.test(text),
51+
`stub leaked the source — found fakePrismaClient reference`);
52+
}
53+
54+
test('guardrail: .server.ts request returns RPC stub, never source', async () => {
55+
const appDir = makeApp({
56+
'app/page.ts': `export default function P() { return 'ok'; }`,
57+
'modules/posts/queries/list-posts.server.ts':
58+
`const SECRET_DB_PASSWORD = 'hunter2';\n` +
59+
`const fakePrismaClient = () => ({ findMany: () => [] });\n` +
60+
`export async function listPosts() { return []; }\n`,
61+
});
62+
const app = await createRequestHandler({ appDir, dev: true });
63+
const resp = await app.handle(new Request(
64+
'http://localhost/modules/posts/queries/list-posts.server.ts'
65+
));
66+
assert.equal(resp.status, 200);
67+
assert.equal(
68+
resp.headers.get('content-type'),
69+
'application/javascript; charset=utf-8'
70+
);
71+
assertIsStub(await resp.text());
72+
});
73+
74+
test(`guardrail: 'use server' plain .ts never leaks source`, async () => {
75+
const appDir = makeApp({
76+
'app/page.ts': `export default function P() { return 'ok'; }`,
77+
'lib/prisma.ts':
78+
`'use server';\n` +
79+
`const SECRET_DB_PASSWORD = 'hunter2';\n` +
80+
`const fakePrismaClient = () => ({ findMany: () => [] });\n` +
81+
`export const prisma = fakePrismaClient();\n`,
82+
});
83+
const app = await createRequestHandler({ appDir, dev: true });
84+
const resp = await app.handle(new Request(
85+
'http://localhost/lib/prisma.ts'
86+
));
87+
assert.equal(resp.status, 200);
88+
assertIsStub(await resp.text());
89+
});
90+
91+
test(`guardrail: 'use server' plain .js never leaks source`, async () => {
92+
const appDir = makeApp({
93+
'app/page.ts': `export default function P() { return 'ok'; }`,
94+
'lib/secret.js':
95+
`"use server";\n` +
96+
`const SECRET_DB_PASSWORD = 'hunter2';\n` +
97+
`export const secret = 'nope';\n`,
98+
});
99+
const app = await createRequestHandler({ appDir, dev: true });
100+
const resp = await app.handle(new Request(
101+
'http://localhost/lib/secret.js'
102+
));
103+
assert.equal(resp.status, 200);
104+
assertIsStub(await resp.text());
105+
});
106+
107+
test('guardrail: .server.js request returns RPC stub, never source', async () => {
108+
const appDir = makeApp({
109+
'app/page.ts': `export default function P() { return 'ok'; }`,
110+
'lib/action.server.js':
111+
`const SECRET_DB_PASSWORD = 'hunter2';\n` +
112+
`export async function doWork() { return 1; }\n`,
113+
});
114+
const app = await createRequestHandler({ appDir, dev: true });
115+
const resp = await app.handle(new Request(
116+
'http://localhost/lib/action.server.js'
117+
));
118+
assert.equal(resp.status, 200);
119+
assertIsStub(await resp.text());
120+
});
121+
122+
test('guardrail: ordinary .ts files still serve source (negative control)', async () => {
123+
const appDir = makeApp({
124+
'app/page.ts': `export default function P() { return 'ok'; }`,
125+
'components/widget.ts':
126+
`export function hello() { return 'hi'; }\n`,
127+
});
128+
const app = await createRequestHandler({ appDir, dev: true });
129+
const resp = await app.handle(new Request(
130+
'http://localhost/components/widget.ts'
131+
));
132+
assert.equal(resp.status, 200);
133+
const text = await resp.text();
134+
// Non-server .ts files are compiled (TS stripped) and served as JS.
135+
assert.ok(!text.startsWith('// webjs: generated server-action stub'),
136+
'ordinary .ts should NOT be stubbed');
137+
assert.ok(/function hello/.test(text),
138+
'ordinary .ts source should be present');
139+
});
140+
141+
test('guardrail: file created AFTER boot is still caught (index race)', async () => {
142+
// Simulates the race window: the action index is built at boot, but a
143+
// developer adds a new .server.ts during dev. The guardrail must catch
144+
// it on first request regardless of index state.
145+
const appDir = makeApp({
146+
'app/page.ts': `export default function P() { return 'ok'; }`,
147+
});
148+
const app = await createRequestHandler({ appDir, dev: true });
149+
150+
// Write the server file AFTER createRequestHandler returned.
151+
const lateFile = join(appDir, 'modules/late.server.ts');
152+
mkdirSync(join(lateFile, '..'), { recursive: true });
153+
writeFileSync(lateFile,
154+
`const SECRET_DB_PASSWORD = 'hunter2';\n` +
155+
`export async function late() { return 42; }\n`);
156+
157+
const resp = await app.handle(new Request(
158+
'http://localhost/modules/late.server.ts'
159+
));
160+
assert.equal(resp.status, 200);
161+
assertIsStub(await resp.text());
162+
});

0 commit comments

Comments
 (0)