rwsdk 1.2.1 → 1.2.3
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/dist/runtime/register/methodEnforcer.d.ts +2 -1
- package/dist/runtime/register/methodEnforcer.js +37 -1
- package/dist/runtime/register/methodEnforcer.test.js +115 -6
- package/dist/runtime/register/worker.d.ts +3 -0
- package/dist/runtime/register/worker.js +7 -0
- package/dist/runtime/worker.d.ts +4 -1
- package/dist/runtime/worker.js +5 -2
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ type DecodeReply = (data: string | FormData, moduleMap: null) => Promise<unknown
|
|
|
3
3
|
export interface RscActionHandlerDeps {
|
|
4
4
|
getServerModuleExport: GetServerModuleExport;
|
|
5
5
|
decodeReply: DecodeReply;
|
|
6
|
+
allowedOrigins?: readonly string[];
|
|
6
7
|
}
|
|
7
|
-
export declare function rscActionHandler(req: Request, { getServerModuleExport, decodeReply }: RscActionHandlerDeps): Promise<unknown>;
|
|
8
|
+
export declare function rscActionHandler(req: Request, { getServerModuleExport, decodeReply, allowedOrigins }: RscActionHandlerDeps): Promise<unknown>;
|
|
8
9
|
export {};
|
|
@@ -2,9 +2,45 @@
|
|
|
2
2
|
// The real getServerModuleExport and decodeReply require the react-server environment
|
|
3
3
|
// (virtual module lookup, react-server-dom-webpack), so we accept them as injected
|
|
4
4
|
// dependencies. worker.ts passes the real implementations; tests pass fakes.
|
|
5
|
-
|
|
5
|
+
// context(justinvdm, 2026-04-20): Origin validation for non-GET action requests.
|
|
6
|
+
// Method enforcement alone does not distinguish a legitimate same-origin POST
|
|
7
|
+
// from a POST driven by a same-site sibling origin (e.g. sibling subdomain,
|
|
8
|
+
// another localhost port). SameSite=Lax cookies are attached in both cases, and
|
|
9
|
+
// the action request's Content-Type is CORS-safelisted, so no preflight fires.
|
|
10
|
+
// We require the request's Origin header to match the app's own origin, unless
|
|
11
|
+
// the caller origin is listed in allowedOrigins.
|
|
12
|
+
function validateSameOrigin(req, allowedOrigins) {
|
|
13
|
+
const origin = req.headers.get("Origin");
|
|
14
|
+
if (!origin) {
|
|
15
|
+
return {
|
|
16
|
+
valid: false,
|
|
17
|
+
response: new Response("Missing Origin header", { status: 403 }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const selfOrigin = new URL(req.url).origin;
|
|
21
|
+
if (origin === selfOrigin) {
|
|
22
|
+
return { valid: true };
|
|
23
|
+
}
|
|
24
|
+
if (allowedOrigins && allowedOrigins.includes(origin)) {
|
|
25
|
+
return { valid: true };
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
valid: false,
|
|
29
|
+
response: new Response("Origin not allowed", { status: 403 }),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export async function rscActionHandler(req, { getServerModuleExport, decodeReply, allowedOrigins }) {
|
|
6
33
|
const url = new URL(req.url);
|
|
7
34
|
const contentType = req.headers.get("content-type");
|
|
35
|
+
// context(justinvdm, 2026-04-20): Enforce Origin/Host match for non-GET action
|
|
36
|
+
// requests. GET (serverQuery) is expected to be idempotent and is not subject
|
|
37
|
+
// to this check.
|
|
38
|
+
if (req.method !== "GET") {
|
|
39
|
+
const originCheck = validateSameOrigin(req, allowedOrigins);
|
|
40
|
+
if (!originCheck.valid) {
|
|
41
|
+
return originCheck.response;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
8
44
|
let args = [];
|
|
9
45
|
if (req.method === "GET") {
|
|
10
46
|
const argsParam = url.searchParams.get("args");
|
|
@@ -6,15 +6,30 @@ function createDeps(action) {
|
|
|
6
6
|
decodeReply: vi.fn().mockResolvedValue([]),
|
|
7
7
|
};
|
|
8
8
|
}
|
|
9
|
-
function makeRequest(url, method, body) {
|
|
9
|
+
function makeRequest(url, method, body, extraHeaders) {
|
|
10
10
|
const init = { method };
|
|
11
|
+
const headers = {};
|
|
11
12
|
if (body !== undefined) {
|
|
12
13
|
init.body = body;
|
|
13
|
-
|
|
14
|
+
headers["content-type"] = "application/json";
|
|
15
|
+
}
|
|
16
|
+
if (extraHeaders) {
|
|
17
|
+
Object.assign(headers, extraHeaders);
|
|
18
|
+
}
|
|
19
|
+
if (Object.keys(headers).length > 0) {
|
|
20
|
+
init.headers = headers;
|
|
14
21
|
}
|
|
15
22
|
return new Request(url, init);
|
|
16
23
|
}
|
|
17
24
|
const ACTION_URL = "https://app.test/page?__rsc_action_id=%2Factions.tsx%23doThing";
|
|
25
|
+
const SELF_ORIGIN = "https://app.test";
|
|
26
|
+
function postWithOrigin(origin) {
|
|
27
|
+
const headers = {};
|
|
28
|
+
if (origin !== undefined) {
|
|
29
|
+
headers["Origin"] = origin;
|
|
30
|
+
}
|
|
31
|
+
return makeRequest(ACTION_URL, "POST", "[]", headers);
|
|
32
|
+
}
|
|
18
33
|
describe("rscActionHandler method enforcement", () => {
|
|
19
34
|
it("rejects GET for an action with method POST", async () => {
|
|
20
35
|
const action = Object.assign(vi.fn(), { method: "POST" });
|
|
@@ -30,7 +45,7 @@ describe("rscActionHandler method enforcement", () => {
|
|
|
30
45
|
it("rejects POST for an action with method GET", async () => {
|
|
31
46
|
const action = Object.assign(vi.fn(), { method: "GET" });
|
|
32
47
|
const deps = createDeps(action);
|
|
33
|
-
const req = makeRequest(ACTION_URL, "POST", "[]");
|
|
48
|
+
const req = makeRequest(ACTION_URL, "POST", "[]", { Origin: SELF_ORIGIN });
|
|
34
49
|
const result = await rscActionHandler(req, deps);
|
|
35
50
|
expect(result).toBeInstanceOf(Response);
|
|
36
51
|
const res = result;
|
|
@@ -43,7 +58,7 @@ describe("rscActionHandler method enforcement", () => {
|
|
|
43
58
|
method: "POST",
|
|
44
59
|
});
|
|
45
60
|
const deps = createDeps(action);
|
|
46
|
-
const req = makeRequest(ACTION_URL, "POST", "[]");
|
|
61
|
+
const req = makeRequest(ACTION_URL, "POST", "[]", { Origin: SELF_ORIGIN });
|
|
47
62
|
const result = await rscActionHandler(req, deps);
|
|
48
63
|
expect(result).toBe("ok");
|
|
49
64
|
expect(action).toHaveBeenCalled();
|
|
@@ -61,7 +76,9 @@ describe("rscActionHandler method enforcement", () => {
|
|
|
61
76
|
it("defaults to POST for actions without .method property", async () => {
|
|
62
77
|
const action = vi.fn().mockReturnValue("ok");
|
|
63
78
|
const deps = createDeps(action);
|
|
64
|
-
const postReq = makeRequest(ACTION_URL, "POST", "[]"
|
|
79
|
+
const postReq = makeRequest(ACTION_URL, "POST", "[]", {
|
|
80
|
+
Origin: SELF_ORIGIN,
|
|
81
|
+
});
|
|
65
82
|
const postResult = await rscActionHandler(postReq, deps);
|
|
66
83
|
expect(postResult).toBe("ok");
|
|
67
84
|
expect(action).toHaveBeenCalled();
|
|
@@ -120,9 +137,101 @@ describe("rscActionHandler method enforcement", () => {
|
|
|
120
137
|
getServerModuleExport: vi.fn().mockResolvedValue(action),
|
|
121
138
|
decodeReply: vi.fn().mockResolvedValue(["decoded-arg"]),
|
|
122
139
|
};
|
|
123
|
-
const req = makeRequest(ACTION_URL, "POST", "serialized-body"
|
|
140
|
+
const req = makeRequest(ACTION_URL, "POST", "serialized-body", {
|
|
141
|
+
Origin: SELF_ORIGIN,
|
|
142
|
+
});
|
|
124
143
|
await rscActionHandler(req, deps);
|
|
125
144
|
expect(deps.decodeReply).toHaveBeenCalledWith("serialized-body", null);
|
|
126
145
|
expect(action).toHaveBeenCalledWith("decoded-arg");
|
|
127
146
|
});
|
|
128
147
|
});
|
|
148
|
+
describe("rscActionHandler origin enforcement", () => {
|
|
149
|
+
it("allows POST whose Origin matches the request's own origin", async () => {
|
|
150
|
+
const action = Object.assign(vi.fn().mockReturnValue("ok"), {
|
|
151
|
+
method: "POST",
|
|
152
|
+
});
|
|
153
|
+
const deps = createDeps(action);
|
|
154
|
+
const req = postWithOrigin(SELF_ORIGIN);
|
|
155
|
+
const result = await rscActionHandler(req, deps);
|
|
156
|
+
expect(result).toBe("ok");
|
|
157
|
+
expect(action).toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
it("rejects POST from a different origin with 403", async () => {
|
|
160
|
+
const action = Object.assign(vi.fn(), { method: "POST" });
|
|
161
|
+
const deps = createDeps(action);
|
|
162
|
+
const req = postWithOrigin("https://evil.test");
|
|
163
|
+
const result = await rscActionHandler(req, deps);
|
|
164
|
+
expect(result).toBeInstanceOf(Response);
|
|
165
|
+
expect(result.status).toBe(403);
|
|
166
|
+
expect(action).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
it("rejects POST from a same-site sibling subdomain with 403", async () => {
|
|
169
|
+
// context(justinvdm, 2026-04-20): The exact scenario in the advisory —
|
|
170
|
+
// Origin sits under the same registrable domain so SameSite=Lax cookies
|
|
171
|
+
// are attached, but the Origin header does not match the app's own origin.
|
|
172
|
+
const action = Object.assign(vi.fn(), { method: "POST" });
|
|
173
|
+
const deps = createDeps(action);
|
|
174
|
+
const req = postWithOrigin("https://evil.test.example");
|
|
175
|
+
const result = await rscActionHandler(req, deps);
|
|
176
|
+
expect(result).toBeInstanceOf(Response);
|
|
177
|
+
expect(result.status).toBe(403);
|
|
178
|
+
expect(action).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
it("rejects POST missing the Origin header with 403", async () => {
|
|
181
|
+
const action = Object.assign(vi.fn(), { method: "POST" });
|
|
182
|
+
const deps = createDeps(action);
|
|
183
|
+
const req = postWithOrigin(undefined);
|
|
184
|
+
const result = await rscActionHandler(req, deps);
|
|
185
|
+
expect(result).toBeInstanceOf(Response);
|
|
186
|
+
expect(result.status).toBe(403);
|
|
187
|
+
expect(action).not.toHaveBeenCalled();
|
|
188
|
+
});
|
|
189
|
+
it("allows POST from an origin in the allowedOrigins list", async () => {
|
|
190
|
+
const action = Object.assign(vi.fn().mockReturnValue("ok"), {
|
|
191
|
+
method: "POST",
|
|
192
|
+
});
|
|
193
|
+
const deps = {
|
|
194
|
+
...createDeps(action),
|
|
195
|
+
allowedOrigins: ["https://trusted.example"],
|
|
196
|
+
};
|
|
197
|
+
const req = postWithOrigin("https://trusted.example");
|
|
198
|
+
const result = await rscActionHandler(req, deps);
|
|
199
|
+
expect(result).toBe("ok");
|
|
200
|
+
expect(action).toHaveBeenCalled();
|
|
201
|
+
});
|
|
202
|
+
it("rejects POST from an origin not in the allowedOrigins list", async () => {
|
|
203
|
+
const action = Object.assign(vi.fn(), { method: "POST" });
|
|
204
|
+
const deps = {
|
|
205
|
+
...createDeps(action),
|
|
206
|
+
allowedOrigins: ["https://trusted.example"],
|
|
207
|
+
};
|
|
208
|
+
const req = postWithOrigin("https://not-trusted.example");
|
|
209
|
+
const result = await rscActionHandler(req, deps);
|
|
210
|
+
expect(result).toBeInstanceOf(Response);
|
|
211
|
+
expect(result.status).toBe(403);
|
|
212
|
+
expect(action).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
it("does not apply the origin check to GET (serverQuery) requests", async () => {
|
|
215
|
+
// context(justinvdm, 2026-04-20): GET is expected to be idempotent and is
|
|
216
|
+
// out of scope for the origin check. A top-level GET navigation from
|
|
217
|
+
// another origin does not send an Origin header at all, so enforcing here
|
|
218
|
+
// would reject legitimate navigations.
|
|
219
|
+
const action = Object.assign(vi.fn().mockReturnValue("data"), {
|
|
220
|
+
method: "GET",
|
|
221
|
+
});
|
|
222
|
+
const deps = createDeps(action);
|
|
223
|
+
const req = makeRequest(ACTION_URL + "&args=%5B%5D", "GET");
|
|
224
|
+
const result = await rscActionHandler(req, deps);
|
|
225
|
+
expect(result).toBe("data");
|
|
226
|
+
expect(action).toHaveBeenCalled();
|
|
227
|
+
});
|
|
228
|
+
it("runs origin check before invoking the action", async () => {
|
|
229
|
+
const action = Object.assign(vi.fn(), { method: "POST" });
|
|
230
|
+
const deps = createDeps(action);
|
|
231
|
+
const req = postWithOrigin("https://evil.test");
|
|
232
|
+
await rscActionHandler(req, deps);
|
|
233
|
+
expect(deps.getServerModuleExport).not.toHaveBeenCalled();
|
|
234
|
+
expect(deps.decodeReply).not.toHaveBeenCalled();
|
|
235
|
+
expect(action).not.toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export declare function registerServerReference(action: Function, id: string, name: string): Function;
|
|
2
2
|
export declare function registerClientReference<Target extends Record<string, unknown>>(ssrModule: Target, id: string, exportName: string): {};
|
|
3
3
|
export declare function __smokeTestActionHandler(timestamp?: number): Promise<unknown>;
|
|
4
|
+
export declare function createRscActionHandler(options?: {
|
|
5
|
+
allowedOrigins?: readonly string[];
|
|
6
|
+
}): (req: Request) => Promise<unknown>;
|
|
4
7
|
export declare function rscActionHandler(req: Request): Promise<unknown>;
|
|
@@ -48,6 +48,13 @@ export async function __smokeTestActionHandler(timestamp) {
|
|
|
48
48
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
49
49
|
return { status: "ok", timestamp };
|
|
50
50
|
}
|
|
51
|
+
export function createRscActionHandler(options = {}) {
|
|
52
|
+
return (req) => rscActionHandlerImpl(req, {
|
|
53
|
+
getServerModuleExport,
|
|
54
|
+
decodeReply,
|
|
55
|
+
allowedOrigins: options.allowedOrigins,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
51
58
|
export async function rscActionHandler(req) {
|
|
52
59
|
return rscActionHandlerImpl(req, { getServerModuleExport, decodeReply });
|
|
53
60
|
}
|
package/dist/runtime/worker.d.ts
CHANGED
|
@@ -12,7 +12,10 @@ export type AppDefinition<Routes extends readonly Route<any>[], T extends Reques
|
|
|
12
12
|
fetch: (request: Request, env: Env, cf: ExecutionContext) => Promise<Response>;
|
|
13
13
|
__rwRoutes: Routes;
|
|
14
14
|
};
|
|
15
|
-
export
|
|
15
|
+
export interface DefineAppOptions {
|
|
16
|
+
allowedOrigins?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
export declare const defineApp: <T extends RequestInfo = RequestInfo<any, DefaultAppContext>, Routes extends readonly Route<T>[] = readonly Route<T>[]>(routes: Routes, options?: DefineAppOptions) => AppDefinition<Routes, T>;
|
|
16
19
|
export declare const DefaultDocument: React.FC<{
|
|
17
20
|
children: React.ReactNode;
|
|
18
21
|
}>;
|
package/dist/runtime/worker.js
CHANGED
|
@@ -4,14 +4,17 @@ import { renderDocumentHtmlStream } from "./render/renderDocumentHtmlStream";
|
|
|
4
4
|
import { renderToRscStream } from "./render/renderToRscStream";
|
|
5
5
|
import { injectRSCPayload } from "rsc-html-stream/server";
|
|
6
6
|
import { ErrorResponse } from "./error";
|
|
7
|
-
import {
|
|
7
|
+
import { createRscActionHandler } from "./register/worker";
|
|
8
8
|
import { getRequestInfo, runWithRequestInfo, runWithRequestInfoOverrides, } from "./requestInfo/worker";
|
|
9
9
|
import { ssrWebpackRequire } from "./imports/worker";
|
|
10
10
|
import { defineRoutes } from "./lib/router";
|
|
11
11
|
import { generateNonce } from "./lib/utils";
|
|
12
12
|
export * from "./requestInfo/types";
|
|
13
|
-
export const defineApp = (routes) => {
|
|
13
|
+
export const defineApp = (routes, options = {}) => {
|
|
14
14
|
const router = defineRoutes(routes);
|
|
15
|
+
const rscActionHandler = createRscActionHandler({
|
|
16
|
+
allowedOrigins: options.allowedOrigins,
|
|
17
|
+
});
|
|
15
18
|
return {
|
|
16
19
|
__rwRoutes: routes,
|
|
17
20
|
fetch: async (request, env, cf) => {
|