Skip to content

Commit 9cbb16c

Browse files
fix(ext/node): don't use user manipulated Response objects, use correct rawHeaders structure
Fixes #28022 Previously, when a user manipulated the global `Response` object, we would use that object when passing the response from the Node shim to `Deno.serve`. This caused issues when the user manipulated the `Response` object in a way that would break `Deno.serve`. The raw headers bug is that we were exposing the `rawHeaders` field on `Incoming` as a `Headers` object, instead it's supposed to be a flat array of the header keys + values. I.e. `["Content-Type:", "application/json", "Host:", "http://localhost"]` Co-authored-by: Nathan Whitaker <[email protected]>
1 parent d3a383e commit 9cbb16c

File tree

5 files changed

+230
-3
lines changed

5 files changed

+230
-3
lines changed

ext/node/polyfills/http.ts

+16-3
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
import { getTimerDuration } from "ext:deno_node/internal/timers.mjs";
6868
import { serve, upgradeHttpRaw } from "ext:deno_http/00_serve.ts";
6969
import { headersEntries } from "ext:deno_fetch/20_headers.js";
70+
import { Response } from "ext:deno_fetch/23_response.js";
7071
import {
7172
builtinTracer,
7273
ContextManager,
@@ -1780,6 +1781,8 @@ Object.defineProperty(ServerResponse.prototype, "connection", {
17801781
),
17811782
});
17821783

1784+
const kRawHeaders = Symbol("rawHeaders");
1785+
17831786
// TODO(@AaronO): optimize
17841787
export class IncomingMessageForServer extends NodeReadable {
17851788
#headers: Record<string, string>;
@@ -1819,7 +1822,7 @@ export class IncomingMessageForServer extends NodeReadable {
18191822
this.method = "";
18201823
this.socket = socket;
18211824
this.upgrade = null;
1822-
this.rawHeaders = [];
1825+
this[kRawHeaders] = [];
18231826
socket?.on("error", (e) => {
18241827
if (this.listenerCount("error") > 0) {
18251828
this.emit("error", e);
@@ -1842,7 +1845,7 @@ export class IncomingMessageForServer extends NodeReadable {
18421845
get headers() {
18431846
if (!this.#headers) {
18441847
this.#headers = {};
1845-
const entries = headersEntries(this.rawHeaders);
1848+
const entries = headersEntries(this[kRawHeaders]);
18461849
for (let i = 0; i < entries.length; i++) {
18471850
const entry = entries[i];
18481851
this.#headers[entry[0]] = entry[1];
@@ -1855,6 +1858,16 @@ export class IncomingMessageForServer extends NodeReadable {
18551858
this.#headers = val;
18561859
}
18571860

1861+
get rawHeaders() {
1862+
const entries = headersEntries(this[kRawHeaders]);
1863+
const out = new Array(entries.length * 2);
1864+
for (let i = 0; i < entries.length; i++) {
1865+
out[i * 2] = entries[i][0];
1866+
out[i * 2 + 1] = entries[i][1];
1867+
}
1868+
return out;
1869+
}
1870+
18581871
// connection is deprecated, but still tested in unit test.
18591872
get connection() {
18601873
return this.socket;
@@ -1959,7 +1972,7 @@ export class ServerImpl extends EventEmitter {
19591972
req.upgrade =
19601973
request.headers.get("connection")?.toLowerCase().includes("upgrade") &&
19611974
request.headers.get("upgrade");
1962-
req.rawHeaders = request.headers;
1975+
req[kRawHeaders] = request.headers;
19631976

19641977
if (req.upgrade && this.listenerCount("upgrade") > 0) {
19651978
const { conn, response } = upgradeHttpRaw(request);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"tempDir": true,
3+
4+
"steps": [{
5+
"args": "run -A main.ts",
6+
"output": "done\n"
7+
}]
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Adapted from https://github.com/honojs/node-server/blob/1eb73c6d985665e75458ddd08c23bbc1dbdc7bcd/src/listener.ts
2+
// and https://github.com/honojs/node-server/blob/1eb73c6d985665e75458ddd08c23bbc1dbdc7bcd/src/server.ts
3+
import {
4+
buildOutgoingHttpHeaders,
5+
Response as WrappedResponse,
6+
} from "./response.ts";
7+
import { createServer, OutgoingHttpHeaders, ServerResponse } from "node:http";
8+
9+
Object.defineProperty(globalThis, "Response", {
10+
value: WrappedResponse,
11+
});
12+
13+
const { promise, resolve } = Promise.withResolvers<void>();
14+
15+
const responseViaResponseObject = async (
16+
res: Response,
17+
outgoing: ServerResponse,
18+
) => {
19+
const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(
20+
res.headers,
21+
);
22+
23+
if (res.body) {
24+
const buffer = await res.arrayBuffer();
25+
resHeaderRecord["content-length"] = buffer.byteLength;
26+
27+
outgoing.writeHead(res.status, resHeaderRecord);
28+
outgoing.end(new Uint8Array(buffer));
29+
} else {
30+
outgoing.writeHead(res.status, resHeaderRecord);
31+
outgoing.end();
32+
}
33+
};
34+
35+
const server = createServer((_req, res) => {
36+
const response = new Response("Hello, world!");
37+
return responseViaResponseObject(response, res);
38+
});
39+
40+
using _server = {
41+
[Symbol.dispose]() {
42+
server.close();
43+
},
44+
};
45+
46+
server.listen(0, async () => {
47+
const { port } = server.address() as { port: number };
48+
const response = await fetch(`http://localhost:${port}`);
49+
await response.text();
50+
resolve();
51+
});
52+
53+
await promise;
54+
console.log("done");
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// Adapted from https://github.com/honojs/node-server/blob/1eb73c6d985665e75458ddd08c23bbc1dbdc7bcd/src/response.ts
2+
// deno-lint-ignore-file no-explicit-any
3+
//
4+
import type { OutgoingHttpHeaders } from "node:http";
5+
6+
interface InternalBody {
7+
source: string | Uint8Array | FormData | Blob | null;
8+
stream: ReadableStream;
9+
length: number | null;
10+
}
11+
12+
const GlobalResponse = globalThis.Response;
13+
14+
const responseCache = Symbol("responseCache");
15+
const getResponseCache = Symbol("getResponseCache");
16+
export const cacheKey = Symbol("cache");
17+
18+
export const buildOutgoingHttpHeaders = (
19+
headers: Headers | HeadersInit | null | undefined,
20+
): OutgoingHttpHeaders => {
21+
const res: OutgoingHttpHeaders = {};
22+
if (!(headers instanceof Headers)) {
23+
headers = new Headers(headers ?? undefined);
24+
}
25+
26+
const cookies = [];
27+
for (const [k, v] of headers) {
28+
if (k === "set-cookie") {
29+
cookies.push(v);
30+
} else {
31+
res[k] = v;
32+
}
33+
}
34+
if (cookies.length > 0) {
35+
res["set-cookie"] = cookies;
36+
}
37+
res["content-type"] ??= "text/plain; charset=UTF-8";
38+
39+
return res;
40+
};
41+
42+
export class Response {
43+
#body?: BodyInit | null;
44+
#init?: ResponseInit;
45+
46+
[getResponseCache](): typeof GlobalResponse {
47+
delete (this as any)[cacheKey];
48+
return ((this as any)[responseCache] ||= new GlobalResponse(
49+
this.#body,
50+
this.#init,
51+
));
52+
}
53+
54+
constructor(body?: BodyInit | null, init?: ResponseInit) {
55+
this.#body = body;
56+
if (init instanceof Response) {
57+
const cachedGlobalResponse = (init as any)[responseCache];
58+
if (cachedGlobalResponse) {
59+
this.#init = cachedGlobalResponse;
60+
// instantiate GlobalResponse cache and this object always returns value from global.Response
61+
this[getResponseCache]();
62+
return;
63+
} else {
64+
this.#init = init.#init;
65+
}
66+
} else {
67+
this.#init = init;
68+
}
69+
70+
if (
71+
typeof body === "string" ||
72+
typeof (body as ReadableStream)?.getReader !== "undefined"
73+
) {
74+
let headers =
75+
(init?.headers || { "content-type": "text/plain; charset=UTF-8" }) as
76+
| Record<string, string>
77+
| Headers
78+
| OutgoingHttpHeaders;
79+
if (headers instanceof Headers) {
80+
headers = buildOutgoingHttpHeaders(headers);
81+
}
82+
83+
(this as any)[cacheKey] = [init?.status || 200, body, headers];
84+
}
85+
}
86+
}
87+
[
88+
"body",
89+
"bodyUsed",
90+
"headers",
91+
"ok",
92+
"redirected",
93+
"status",
94+
"statusText",
95+
"trailers",
96+
"type",
97+
"url",
98+
].forEach((k) => {
99+
Object.defineProperty(Response.prototype, k, {
100+
get() {
101+
return this[getResponseCache]()[k];
102+
},
103+
});
104+
});
105+
["arrayBuffer", "blob", "clone", "formData", "json", "text"].forEach((k) => {
106+
Object.defineProperty(Response.prototype, k, {
107+
value: function () {
108+
return this[getResponseCache]()[k]();
109+
},
110+
});
111+
});
112+
Object.setPrototypeOf(Response, GlobalResponse);
113+
Object.setPrototypeOf(Response.prototype, GlobalResponse.prototype);

tests/unit_node/http_test.ts

+39
Original file line numberDiff line numberDiff line change
@@ -2041,3 +2041,42 @@ Deno.test("[node/http] 'close' event is emitted on ServerResponse object when th
20412041
await new Promise((resolve) => server.close(resolve));
20422042
assert(responseCloseEmitted);
20432043
});
2044+
2045+
Deno.test("[node/http] rawHeaders are in flattened format", async () => {
2046+
const getHeader = (req: IncomingMessage, name: string) => {
2047+
const idx = req.rawHeaders.indexOf(name);
2048+
if (idx < 0) {
2049+
throw new Error(`Header ${name} not found`);
2050+
}
2051+
return [name, req.rawHeaders[idx + 1]];
2052+
};
2053+
const { promise, resolve } = Promise.withResolvers<void>();
2054+
const server = http.createServer((req, res) => {
2055+
resolve();
2056+
// TODO(nathanwhit): the raw headers should not be lowercased, they should be
2057+
// exactly as they appeared in the request
2058+
assertEquals(getHeader(req, "content-type"), [
2059+
"content-type",
2060+
"text/plain",
2061+
]);
2062+
assertEquals(getHeader(req, "set-cookie"), [
2063+
"set-cookie",
2064+
"foo=bar",
2065+
]);
2066+
res.end();
2067+
});
2068+
2069+
server.listen(0, async () => {
2070+
const { port } = server.address() as { port: number };
2071+
const response = await fetch(`http://localhost:${port}`, {
2072+
headers: {
2073+
"Set-Cookie": "foo=bar",
2074+
"Content-Type": "text/plain",
2075+
},
2076+
});
2077+
await response.body?.cancel();
2078+
});
2079+
2080+
await promise;
2081+
await new Promise((resolve) => server.close(resolve));
2082+
});

0 commit comments

Comments
 (0)