diff --git a/.changeset/tidy-planes-brush.md b/.changeset/tidy-planes-brush.md new file mode 100644 index 000000000..5cb16c6c0 --- /dev/null +++ b/.changeset/tidy-planes-brush.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Declare an explicit `Content-Type: text/plain; charset=utf-8` on seroval-stream server function responses (success and error paths) so intermediaries cannot content-sniff a type onto them diff --git a/packages/start/src/fns/handler.spec.ts b/packages/start/src/fns/handler.spec.ts index 58594bb4c..5ba67d3e2 100644 --- a/packages/start/src/fns/handler.spec.ts +++ b/packages/start/src/fns/handler.spec.ts @@ -282,3 +282,57 @@ describe("the configured server function error handler", () => { expect(h3Event.res.headers.get("X-Error")).toBe("boom"); }); }); + +describe("seroval stream response headers", () => { + const callReturning = async (value: unknown) => { + const request = new Request("http://localhost/_server", { + method: "POST", + headers: { "X-Server-Id": "fn", "X-Server-Instance": "server-fn:1" }, + }); + const h3Event = { res: { headers: new Headers(), status: 200 } }; + vi.mocked(getFetchEvent).mockReturnValue({ + request, + response: { headers: { getSetCookie: () => [] } }, + nativeEvent: h3Event, + locals: {}, + } as unknown as FetchEvent); + vi.mocked(getServerFunction).mockReturnValue(() => value); + const { handleServerFunction } = await import("./handler.ts"); + await handleServerFunction(h3Event as never); + return h3Event; + }; + + beforeEach(() => { + vi.clearAllMocks(); + configuredErrorHandler.current = undefined; + }); + + it("sets an explicit content type on serialized results so intermediaries cannot sniff one", async () => { + const h3Event = await callReturning({ some: "value" }); + + expect(h3Event.res.headers.get("X-Start-Type")).toBe("0"); + expect(h3Event.res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + }); + + it("sets an explicit content type on serialized errors", async () => { + const request = new Request("http://localhost/_server", { + method: "POST", + headers: { "X-Server-Id": "fn", "X-Server-Instance": "server-fn:1" }, + }); + const h3Event = { res: { headers: new Headers(), status: 200 } }; + vi.mocked(getFetchEvent).mockReturnValue({ + request, + response: { headers: { getSetCookie: () => [] } }, + nativeEvent: h3Event, + locals: {}, + } as unknown as FetchEvent); + vi.mocked(getServerFunction).mockReturnValue(() => { + throw new Error("boom"); + }); + const { handleServerFunction } = await import("./handler.ts"); + await handleServerFunction(h3Event as never); + + expect(h3Event.res.headers.get("X-Start-Type")).toBe("0"); + expect(h3Event.res.headers.get("content-type")).toBe("text/plain; charset=utf-8"); + }); +}); diff --git a/packages/start/src/fns/handler.ts b/packages/start/src/fns/handler.ts index 1c6d994f4..bb866b606 100644 --- a/packages/start/src/fns/handler.ts +++ b/packages/start/src/fns/handler.ts @@ -125,6 +125,8 @@ export async function handleServerFunction(h3Event: H3Event) { h3Event.res.headers.set("content-type", "text/javascript"); return serializeToJSStream(instance, result); } + // explicit type keeps intermediaries (e.g. Go proxies) from sniffing one onto the stream + h3Event.res.headers.set("content-type", "text/plain; charset=utf-8"); return serializeToJSONStream(result); } catch (x) { x = await applyServerFunctionErrorHandler(x); @@ -165,6 +167,7 @@ export async function handleServerFunction(h3Event: H3Event) { h3Event.res.headers.set("content-type", "text/javascript"); return serializeToJSStream(instance, x); } + h3Event.res.headers.set("content-type", "text/plain; charset=utf-8"); return serializeToJSONStream(x); } return x;