Skip to content

Commit cf72805

Browse files
committed
fix: close the same form-action leak in the streaming renderer
The first pass guarded `renderTemplate` only. SSR has a SECOND, independent state machine, `streamTemplate`, on the Suspense streaming path, and it had no guard at all, so a streamed page still wrote a bound function's whole source into the response while the buffered path already refused it. Two renderers with one contract between them is what allowed the gap, so the decision now lives in a single `commitFormActionAttr` helper both call, rather than in duplicated logic at each site. The streaming path gets its own tests instead of being assumed to inherit the buffered path's. Also adds the identity plumbing the form binding needs: a server-installed resolver (the same shape as the CSP nonce provider, so the browser bundle never gets one and keeps refusing every function), the buffered hidden-field emission, and the forced method/enctype that a no-JS submission depends on.
1 parent 7d86696 commit cf72805

3 files changed

Lines changed: 224 additions & 30 deletions

File tree

packages/core/src/form-action.js

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,124 @@ export const ACTION_FIELD = '__webjs_action';
3232
*/
3333
export function assertNotFunctionActionAttr(val, attrName, tag) {
3434
if (typeof val !== 'function') return;
35+
if (!isFormActionAttr(attrName)) return;
36+
throw new Error(formActionError(attrName, tag));
37+
}
38+
39+
/** @param {string} attrName @returns {boolean} */
40+
export function isFormActionAttr(attrName) {
3541
const name = String(attrName).toLowerCase();
36-
if (name !== 'action' && name !== 'formaction') return;
37-
throw new Error(
38-
`[webjs] a function was interpolated into ${name}= on <${tag || 'form'}>. `
39-
+ `Stringifying a function into HTML would leak its source (a 'use server' `
40-
+ `action's body included) to every visitor, so this is refused. `
41-
+ `Bind a 'use server' action exported from a *.server.{js,ts} module, `
42-
+ `or pass a URL string.`
43-
);
42+
return name === 'action' || name === 'formaction';
43+
}
44+
45+
/**
46+
* The refusal message. Deliberately never echoes the function's source, which
47+
* is the very thing being withheld.
48+
* @param {string} attrName
49+
* @param {string} [tag]
50+
*/
51+
function formActionError(attrName, tag) {
52+
return `[webjs] a function was interpolated into ${String(attrName).toLowerCase()}= `
53+
+ `on <${tag || 'form'}>, and it is not a 'use server' action. Stringifying a `
54+
+ `function into HTML would leak its source (a server action's body included) `
55+
+ `to every visitor, so this is refused. Bind a 'use server' action exported `
56+
+ `from a *.server.{js,ts} module, or pass a URL string.`;
57+
}
58+
59+
/**
60+
* Resolves a form-bound action function to its wire identity, `<hash>/<fn>`
61+
* (#1155). Server-only wiring, installed exactly like the CSP nonce provider:
62+
* `@webjsdev/server` calls `setFormActionResolver` at load time, the browser
63+
* bundle never does, so a client-side render keeps refusing every function.
64+
*
65+
* @type {((fn: Function) => string | null) | null}
66+
*/
67+
let _resolver = null;
68+
69+
/**
70+
* Internal: server-only wiring. Installed once by `@webjsdev/server`.
71+
* @param {(fn: Function) => string | null} fn
72+
*/
73+
export function setFormActionResolver(fn) {
74+
_resolver = fn;
75+
}
76+
77+
/**
78+
* Resolve a form-bound action to `<hash>/<fn>`, or throw the refusal above when
79+
* it is not an identifiable `'use server'` action.
80+
*
81+
* Throwing on an unresolvable function is load-bearing for progressive
82+
* enhancement, not defensive: emitting the form WITHOUT the identity field
83+
* would render a form that posts to the page and dispatches nothing, a silent
84+
* no-op with no error anywhere. Failing the render is the only outcome that
85+
* cannot ship a dead form.
86+
*
87+
* @param {Function} val
88+
* @param {string} attrName
89+
* @param {string} [tag]
90+
* @returns {string} the `<hash>/<fn>` identity
91+
*/
92+
export function resolveFormActionOrThrow(val, attrName, tag) {
93+
const id = _resolver ? _resolver(val) : null;
94+
if (!id) throw new Error(formActionError(attrName, tag));
95+
return id;
96+
}
97+
98+
/**
99+
* The hidden field carrying the action identity, plus the attributes the
100+
* framework forces rather than trusting the author to write.
101+
*
102+
* `method="post"`: a form with no method GETs, so the action would never run.
103+
* `enctype="multipart/form-data"`: without it a no-JS file upload silently
104+
* arrives as a filename string instead of the file.
105+
*
106+
* @param {string} id the `<hash>/<fn>` identity
107+
* @param {(name: string) => boolean} has whether the form already set an attribute
108+
* @returns {{ forced: string, field: string }}
109+
*/
110+
export function formActionMarkup(id, has) {
111+
let forced = '';
112+
if (!has('method')) forced += ' method="post"';
113+
if (!has('enctype')) forced += ' enctype="multipart/form-data"';
114+
const field = `<input type="hidden" name="${ACTION_FIELD}" value="${escapeAttrValue(id)}">`;
115+
return { forced, field };
116+
}
117+
118+
/** Minimal attribute escape; the identity is framework-generated, so this is belt and braces. */
119+
function escapeAttrValue(s) {
120+
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
121+
}
122+
123+
/**
124+
* The single decision point both SSR state machines route through when an
125+
* attribute hole resolves to a function.
126+
*
127+
* There are two of them (`renderTemplate` and the Suspense-streaming
128+
* `streamTemplate`), and they HAVE already diverged once: the guard was added
129+
* to the buffered renderer alone, leaving the streamed path emitting a server
130+
* action's whole body into the response. Anything that must hold for both
131+
* belongs here rather than at either call site.
132+
*
133+
* Returns null when the value is not a function bound to an action attribute,
134+
* meaning the caller proceeds with its normal stringify.
135+
*
136+
* @param {unknown} val the resolved hole value
137+
* @param {string} attrName the attribute being committed
138+
* @param {string} tag lowercased owner tag
139+
* @param {Set<string>} seenAttrs attribute names already written on this tag
140+
* @returns {{ attrValue: string, forced: string, field: string } | null}
141+
*/
142+
export function commitFormActionAttr(val, attrName, tag, seenAttrs) {
143+
if (typeof val !== 'function') return null;
144+
if (!isFormActionAttr(attrName)) {
145+
// A function on any other attribute keeps the pre-existing stringify
146+
// behaviour: widening the claim here would change unrelated rendering.
147+
return null;
148+
}
149+
const id = resolveFormActionOrThrow(val, attrName, tag);
150+
const { forced, field } = formActionMarkup(id, (n) => seenAttrs.has(n));
151+
// The form posts to its OWN url; the identity rides the hidden field, which
152+
// is what lets one action be bound from any page (#1155) while the failure
153+
// re-render still targets the page the form was on.
154+
return { attrValue: '""', forced, field };
44155
}

packages/core/src/render-server.js

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { html, isTemplate } from './html.js';
22
import { BINDING_PREFIXES } from './binding-prefixes.js';
33
import { escapeText, escapeAttr } from './escape.js';
4-
import { assertNotFunctionActionAttr } from './form-action.js';
4+
import { assertNotFunctionActionAttr, commitFormActionAttr } from './form-action.js';
55
import { lookup, lookupModuleUrl, allTags } from './registry.js';
66
import { stylesToString, isCSS } from './css.js';
77
import { isRepeat } from './repeat.js';
@@ -152,6 +152,13 @@ async function renderTemplate(tr, ctx) {
152152
let commentDashes = 0;
153153
let currentTag = ''; // lowercased tag name currently being parsed
154154
let rawTail = ''; // rolling lowercased tail, tracks </script>/</style>
155+
// Form-action binding (#1155). A `<form action=${action}>` commits its
156+
// attribute mid-tag, but the identity field it needs is a CHILD, so the
157+
// markup is buffered here and flushed the moment the open tag closes.
158+
// `seenAttrs` tracks what the author already wrote on the current tag so a
159+
// forced `method` / `enctype` never duplicates an explicit one.
160+
let pendingFormField = '';
161+
let seenAttrs = new Set();
155162

156163
for (let i = 0; i < strings.length; i++) {
157164
const s = strings[i];
@@ -195,6 +202,9 @@ async function renderTemplate(tr, ctx) {
195202
case 'in-tag':
196203
out += c;
197204
if (c === '>') {
205+
// The open tag just closed, so a buffered form-action identity
206+
// field can now be emitted as the form's first child (#1155).
207+
if (pendingFormField) { out += pendingFormField; pendingFormField = ''; }
198208
state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
199209
if (state === 'rawtext') rawTail = '';
200210
} else if (!/\s/.test(c) && c !== '/') {
@@ -213,20 +223,34 @@ async function renderTemplate(tr, ctx) {
213223
}
214224
break;
215225
case 'attr-name':
216-
if (c === '=') { state = 'after-eq'; out += c; }
217-
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
218-
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
226+
// Record every attribute the author wrote on this tag, so a forced
227+
// method/enctype (#1155) never duplicates an explicit one.
228+
if (c === '=') { state = 'after-eq'; seenAttrs.add(attrName.toLowerCase()); out += c; }
229+
else if (/\s/.test(c)) { state = 'in-tag'; seenAttrs.add(attrName.toLowerCase()); attrName = ''; out += c; }
230+
else if (c === '>') {
231+
state = 'text';
232+
seenAttrs.add(attrName.toLowerCase());
233+
attrName = '';
234+
out += c;
235+
if (pendingFormField) { out += pendingFormField; pendingFormField = ''; }
236+
}
219237
else { attrName += c; out += c; }
220238
break;
221239
case 'after-eq':
222240
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; out += c; }
223241
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
224-
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
242+
else if (c === '>') {
243+
state = 'text'; attrName = ''; out += c;
244+
if (pendingFormField) { out += pendingFormField; pendingFormField = ''; }
245+
}
225246
else { state = 'attr-unquoted'; out += c; }
226247
break;
227248
case 'attr-unquoted':
228249
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; out += c; }
229-
else if (c === '>') { state = 'text'; attrName = ''; out += c; }
250+
else if (c === '>') {
251+
state = 'text'; attrName = ''; out += c;
252+
if (pendingFormField) { out += pendingFormField; pendingFormField = ''; }
253+
}
230254
else out += c;
231255
break;
232256
case 'attr-quoted':
@@ -324,16 +348,22 @@ async function renderTemplate(tr, ctx) {
324348
state = 'in-tag';
325349
attrName = '';
326350
} else {
327-
// #1154: never stringify a function into action=/formaction= (it
328-
// would serialize a server action's source into the served HTML).
329-
assertNotFunctionActionAttr(val, attrName, currentTag);
330-
out += `"${escapeAttr(String(val ?? ''))}"`;
351+
// A function bound to action=/formaction= is either an identifiable
352+
// server action (emit the identity field, #1155) or refused
353+
// outright (#1154); it is never stringified.
354+
const bound = commitFormActionAttr(val, attrName, currentTag, seenAttrs);
355+
if (bound) {
356+
out += bound.attrValue + bound.forced;
357+
pendingFormField = bound.field;
358+
} else {
359+
out += `"${escapeAttr(String(val ?? ''))}"`;
360+
}
331361
state = 'in-tag';
332362
attrName = '';
333363
}
334364
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
335-
// Same guard for a hole inside a quoted/unquoted value, the
336-
// `action="${fn}"` and mixed `action="/x/${fn}"` shapes (#1154).
365+
// A hole INSIDE a value (`action="${fn}"`, mixed `action="/x/${fn}"`)
366+
// cannot carry an identity field, so a function here is always refused.
337367
assertNotFunctionActionAttr(val, attrName, currentTag);
338368
out += escapeAttr(String(val ?? ''));
339369
}
@@ -1775,6 +1805,9 @@ async function streamTemplate(tr, ctx, controller) {
17751805
let rawTail = '';
17761806
// Buffer used for attribute handling where we may need to backtrack.
17771807
let buf = '';
1808+
// Form-action binding (#1155), mirroring renderTemplate.
1809+
let pendingFormField = '';
1810+
let seenAttrs = new Set();
17781811

17791812
for (let i = 0; i < strings.length; i++) {
17801813
const s = strings[i];
@@ -1788,8 +1821,8 @@ async function streamTemplate(tr, ctx, controller) {
17881821
case 'tag-open':
17891822
buf += c;
17901823
if (c === '!') state = 'bang-1';
1791-
else if (c === '/') { state = 'tag-name'; currentTag = ''; }
1792-
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); }
1824+
else if (c === '/') { state = 'tag-name'; currentTag = ''; seenAttrs = new Set(); }
1825+
else if (/[a-zA-Z]/.test(c)) { state = 'tag-name'; currentTag = c.toLowerCase(); seenAttrs = new Set(); }
17931826
else state = 'text';
17941827
break;
17951828
case 'bang-1':
@@ -1818,6 +1851,7 @@ async function streamTemplate(tr, ctx, controller) {
18181851
case 'in-tag':
18191852
buf += c;
18201853
if (c === '>') {
1854+
if (pendingFormField) { buf += pendingFormField; pendingFormField = ''; }
18211855
state = isRawtextTag(currentTag) ? 'rawtext' : 'text';
18221856
if (state === 'rawtext') rawTail = '';
18231857
} else if (!/\s/.test(c) && c !== '/') {
@@ -1836,20 +1870,29 @@ async function streamTemplate(tr, ctx, controller) {
18361870
}
18371871
break;
18381872
case 'attr-name':
1839-
if (c === '=') { state = 'after-eq'; buf += c; }
1840-
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1841-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
1873+
if (c === '=') { state = 'after-eq'; seenAttrs.add(attrName.toLowerCase()); buf += c; }
1874+
else if (/\s/.test(c)) { state = 'in-tag'; seenAttrs.add(attrName.toLowerCase()); attrName = ''; buf += c; }
1875+
else if (c === '>') {
1876+
state = 'text'; seenAttrs.add(attrName.toLowerCase()); attrName = ''; buf += c;
1877+
if (pendingFormField) { buf += pendingFormField; pendingFormField = ''; }
1878+
}
18421879
else { attrName += c; buf += c; }
18431880
break;
18441881
case 'after-eq':
18451882
if (c === '"' || c === "'") { state = 'attr-quoted'; attrQuote = c; buf += c; }
18461883
else if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1847-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
1884+
else if (c === '>') {
1885+
state = 'text'; attrName = ''; buf += c;
1886+
if (pendingFormField) { buf += pendingFormField; pendingFormField = ''; }
1887+
}
18481888
else { state = 'attr-unquoted'; buf += c; }
18491889
break;
18501890
case 'attr-unquoted':
18511891
if (/\s/.test(c)) { state = 'in-tag'; attrName = ''; buf += c; }
1852-
else if (c === '>') { state = 'text'; attrName = ''; buf += c; }
1892+
else if (c === '>') {
1893+
state = 'text'; attrName = ''; buf += c;
1894+
if (pendingFormField) { buf += pendingFormField; pendingFormField = ''; }
1895+
}
18531896
else buf += c;
18541897
break;
18551898
case 'attr-quoted':
@@ -1891,11 +1934,21 @@ async function streamTemplate(tr, ctx, controller) {
18911934
state = 'in-tag';
18921935
attrName = '';
18931936
} else {
1894-
buf += `"${escapeAttr(String(val ?? ''))}"`;
1937+
// Same contract as the buffered renderer (they must not diverge; see
1938+
// commitFormActionAttr). Before this, a STREAMED page emitted a bound
1939+
// action's source verbatim while the buffered path already refused it.
1940+
const bound = commitFormActionAttr(val, attrName, currentTag, seenAttrs);
1941+
if (bound) {
1942+
buf += bound.attrValue + bound.forced;
1943+
pendingFormField = bound.field;
1944+
} else {
1945+
buf += `"${escapeAttr(String(val ?? ''))}"`;
1946+
}
18951947
state = 'in-tag';
18961948
attrName = '';
18971949
}
18981950
} else if (state === 'attr-quoted' || state === 'attr-unquoted') {
1951+
assertNotFunctionActionAttr(val, attrName, currentTag);
18991952
buf += escapeAttr(String(val ?? ''));
19001953
}
19011954
}

packages/core/test/rendering/form-action-attr-guard.test.js

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@
77
import { test, before } from 'node:test';
88
import assert from 'node:assert/strict';
99

10-
let html, renderToString;
10+
let html, renderToString, renderToStream;
1111
before(async () => {
1212
({ html } = await import('../../src/html.js'));
13-
({ renderToString } = await import('../../src/render-server.js'));
13+
({ renderToString, renderToStream } = await import('../../src/render-server.js'));
1414
});
1515

16+
async function drain(stream) {
17+
let out = '';
18+
for await (const c of stream) out += typeof c === 'string' ? c : new TextDecoder().decode(c);
19+
return out;
20+
}
21+
1622
// The secret sentinel must never appear in any output, thrown or not.
1723
const SECRET = 'postgres://user:SECRET@host/db';
1824
async function leaky(input) { const conn = SECRET; return { success: true, conn, input }; }
@@ -72,6 +78,30 @@ test('string-valued action renders byte-identically to before', async () => {
7278
);
7379
});
7480

81+
// The STREAMING renderer is a second, independent state machine. It was missed
82+
// by the first pass of this fix and kept emitting the action's whole body while
83+
// the buffered renderer already refused it, so it gets its own coverage rather
84+
// than being assumed to inherit the guard.
85+
test('the streaming renderer refuses the same function (ssr:false path)', async () => {
86+
await assert.rejects(
87+
() => drain(renderToStream(html`<form action=${leaky}></form>`, { ssr: false })),
88+
/function was interpolated into action=/,
89+
);
90+
});
91+
92+
test('the streaming renderer never emits the source', async () => {
93+
let out = '';
94+
try {
95+
out = await drain(renderToStream(html`<form action=${leaky}></form>`, { ssr: false }));
96+
} catch { /* expected */ }
97+
assert.ok(!out.includes('SECRET'), 'streamed output must not carry the function source');
98+
});
99+
100+
test('streaming keeps string-valued actions working', async () => {
101+
const out = await drain(renderToStream(html`<form action=${'/submit'}></form>`, { ssr: false }));
102+
assert.match(out, /action="\/submit"/);
103+
});
104+
75105
test('functions in OTHER attributes keep the existing stringify behaviour', async () => {
76106
// Narrow claim: only action/formaction throw. Anything else is unchanged
77107
// (arguably also a bug, but out of #1154's scope by design).

0 commit comments

Comments
 (0)