Skip to content

Commit 2e022cd

Browse files
fix: binary-safe HTTP body buffering for multi-segment POST/PUT
Addresses reviewer feedback on PR #1567: - Buffer incoming TCP data as raw bytes (Uint8Array) instead of text-decoding everything. Search for \r\n\r\n in binary, text-decode only the header portion. This prevents corruption of binary POST bodies and ensures the first \r\n\r\n in headers (not in body) is used as the separator. - Replace ad-hoc new TextEncoder()/new TextDecoder() with module-scoped singletons (textEncoder, textDecoder). - Refactor _dispatch_fetch into a standalone dispatch_fetch(conn, ...) helper that takes the connection explicitly, avoiding incorrect binding between the connection and adapter objects. - Add comment noting that Transfer-Encoding: chunked is unsupported. - Add unit tests (tests/devices/fetch_network_post.js): * Small POST body in one segment * Large POST body split across 3 segments * Binary body containing embedded CRLFCRLF bytes * GET request (no body) * Headers split across 2 segments
1 parent 165cfc2 commit 2e022cd

2 files changed

Lines changed: 364 additions & 96 deletions

File tree

src/browser/fetch_network.js

Lines changed: 175 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,28 @@ import {
1313
// For Types Only
1414
import { BusConnector } from "../bus.js";
1515

16+
// Module-scoped encoder/decoder singletons (avoids repeated allocations).
17+
const textEncoder = new TextEncoder();
18+
const textDecoder = new TextDecoder();
19+
20+
/** Find the first occurrence of `needle` bytes in `haystack`. Returns -1 if not found. */
21+
function indexOfBytes(haystack, needle)
22+
{
23+
outer:
24+
for(let i = 0; i <= haystack.length - needle.length; i++)
25+
{
26+
for(let j = 0; j < needle.length; j++)
27+
{
28+
if(haystack[i + j] !== needle[j]) continue outer;
29+
}
30+
return i;
31+
}
32+
return -1;
33+
}
34+
35+
// Pre-encoded \r\n\r\n separator for binary header/body split.
36+
const CRLFCRLF = textEncoder.encode("\r\n\r\n");
37+
1638
/**
1739
* @constructor
1840
*
@@ -69,116 +91,164 @@ FetchNetworkAdapter.prototype.tcp_probe = function(port)
6991
};
7092

7193
/**
94+
* HTTP data handler for port-80 TCP connections.
95+
*
96+
* Incoming TCP segments are buffered as raw bytes and searched for the
97+
* \r\n\r\n header/body separator. Once found the header portion alone is
98+
* text-decoded and parsed; the body stays as a binary Uint8Array.
99+
*
100+
* When a POST/PUT body spans multiple TCP segments the partial body is
101+
* stored in this._xb and subsequent segments are accumulated there until
102+
* Content-Length is satisfied, at which point the deferred fetch is fired.
103+
*
104+
* NOTE: Transfer-Encoding: chunked is not supported. Requests using it
105+
* will not be dispatched.
106+
*
72107
* @this {TCPConnection}
73108
* @param {!ArrayBuffer} data
74109
*/
75110
async function on_data_http(data)
76111
{
77112
// If we're buffering a partial request body, accumulate chunks until
78113
// Content-Length is satisfied, then fire the deferred fetch.
79-
if(this._xb) {
114+
if(this._xb)
115+
{
80116
const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
81117
const combined = new Uint8Array(this._xb.buf.length + chunk.length);
82118
combined.set(this._xb.buf);
83119
combined.set(chunk, this._xb.buf.length);
84120
this._xb.buf = combined;
85-
if(this._xb.buf.length >= this._xb.cl) {
86-
const body = new TextDecoder().decode(this._xb.buf);
121+
if(this._xb.buf.length >= this._xb.cl)
122+
{
123+
const body = this._xb.buf;
87124
const done = this._xb.done;
88125
this._xb = null;
89126
done(body);
90127
}
91128
return;
92129
}
93130

94-
this.read = this.read || "";
95-
this.read += new TextDecoder().decode(data);
96-
if(this.read && this.read.indexOf("\r\n\r\n") !== -1) {
97-
let offset = this.read.indexOf("\r\n\r\n");
98-
let headers = this.read.substring(0, offset).split(/\r\n/);
99-
let data = this.read.substring(offset + 4);
100-
this.read = "";
101-
102-
let first_line = headers[0].split(" ");
103-
let target;
104-
if(/^https?:/.test(first_line[1])) {
105-
// HTTP proxy
106-
target = new URL(first_line[1]);
131+
// Accumulate raw bytes (not text) so binary body data is preserved.
132+
const chunk = data instanceof Uint8Array ? data : new Uint8Array(data);
133+
if(this._raw)
134+
{
135+
const combined = new Uint8Array(this._raw.length + chunk.length);
136+
combined.set(this._raw);
137+
combined.set(chunk, this._raw.length);
138+
this._raw = combined;
139+
}
140+
else
141+
{
142+
this._raw = chunk;
143+
}
144+
145+
const sep_index = indexOfBytes(this._raw, CRLFCRLF);
146+
if(sep_index === -1) return;
147+
148+
// Split into header (text) and body (binary).
149+
const headerBytes = this._raw.slice(0, sep_index);
150+
const bodyBytes = this._raw.slice(sep_index + CRLFCRLF.length);
151+
this._raw = null;
152+
153+
const headerText = textDecoder.decode(headerBytes);
154+
const headerLines = headerText.split(/\r\n/);
155+
156+
const first_line = headerLines[0].split(" ");
157+
let target;
158+
if(/^https?:/.test(first_line[1]))
159+
{
160+
// HTTP proxy
161+
target = new URL(first_line[1]);
162+
}
163+
else
164+
{
165+
target = new URL("http://host" + first_line[1]);
166+
}
167+
if(typeof window !== "undefined" && target.protocol === "http:" && window.location.protocol === "https:")
168+
{
169+
// fix "Mixed Content" errors
170+
target.protocol = "https:";
171+
}
172+
173+
const req_headers = new Headers();
174+
for(let i = 1; i < headerLines.length; ++i)
175+
{
176+
const header = this.net.parse_http_header(headerLines[i]);
177+
if(!header)
178+
{
179+
console.warn('The request contains an invalid header: "%s"', headerLines[i]);
180+
this.net.respond_text_and_close(this, 400, "Bad Request", `Invalid header in request: ${headerLines[i]}`);
181+
return;
107182
}
108-
else {
109-
target = new URL("http://host" + first_line[1]);
183+
if(header.key.toLowerCase() === "host") target.host = header.value;
184+
else req_headers.append(header.key, header.value);
185+
}
186+
187+
if(!this.net.cors_proxy && /^\d+\.external$/.test(target.hostname))
188+
{
189+
dbg_log("Request to localhost: " + target.href, LOG_FETCH);
190+
const localport = parseInt(target.hostname.split(".")[0], 10);
191+
if(!isNaN(localport) && localport > 0 && localport < 65536)
192+
{
193+
target.protocol = "http:";
194+
target.hostname = "localhost";
195+
target.port = localport.toString(10);
110196
}
111-
if(typeof window !== "undefined" && target.protocol === "http:" && window.location.protocol === "https:") {
112-
// fix "Mixed Content" errors
113-
target.protocol = "https:";
197+
else
198+
{
199+
console.warn('Unknown port for localhost: "%s"', target.href);
200+
this.net.respond_text_and_close(this, 400, "Bad Request", `Unknown port for localhost: ${target.href}`);
201+
return;
114202
}
203+
}
115204

116-
let req_headers = new Headers();
117-
for(let i = 1; i < headers.length; ++i) {
118-
const header = this.net.parse_http_header(headers[i]);
119-
if(!header) {
120-
console.warn('The request contains an invalid header: "%s"', headers[i]);
121-
this.net.respond_text_and_close(this, 400, "Bad Request", `Invalid header in request: ${headers[i]}`);
122-
return;
123-
}
124-
if( header.key.toLowerCase() === "host" ) target.host = header.value;
125-
else req_headers.append(header.key, header.value);
126-
}
205+
dbg_log("HTTP Dispatch: " + target.href, LOG_FETCH);
206+
this.name = target.href;
127207

128-
if(!this.net.cors_proxy && /^\d+\.external$/.test(target.hostname)) {
129-
dbg_log("Request to localhost: " + target.href, LOG_FETCH);
130-
const localport = parseInt(target.hostname.split(".")[0], 10);
131-
if(!isNaN(localport) && localport > 0 && localport < 65536) {
132-
target.protocol = "http:";
133-
target.hostname = "localhost";
134-
target.port = localport.toString(10);
135-
} else {
136-
console.warn('Unknown port for localhost: "%s"', target.href);
137-
this.net.respond_text_and_close(this, 400, "Bad Request", `Unknown port for localhost: ${target.href}`);
138-
return;
139-
}
140-
}
208+
const opts = {
209+
method: first_line[0],
210+
headers: req_headers,
211+
};
141212

142-
dbg_log("HTTP Dispatch: " + target.href, LOG_FETCH);
143-
this.name = target.href;
144-
let opts = {
145-
method: first_line[0],
146-
headers: req_headers,
147-
};
148-
if(["put", "post"].indexOf(opts.method.toLowerCase()) !== -1) {
149-
// Check if the body might be split across multiple TCP segments.
150-
// If Content-Length is present and larger than what we have,
151-
// buffer and wait for the remaining chunks.
152-
const content_length = parseInt(req_headers.get("content-length") || "0", 10);
153-
const body_bytes = (data instanceof Uint8Array) ? data : new TextEncoder().encode(data);
154-
if(content_length > 0 && body_bytes.length < content_length) {
155-
const fetch_url = this.net.cors_proxy ? this.net.cors_proxy + encodeURIComponent(target.href) : target.href;
156-
this._xb = {
157-
buf: body_bytes,
158-
cl: content_length,
159-
done: (body) => {
160-
opts.body = body;
161-
this._dispatch_fetch(fetch_url, opts);
162-
},
163-
};
164-
return;
165-
}
166-
opts.body = data;
213+
if(["put", "post"].indexOf(opts.method.toLowerCase()) !== -1)
214+
{
215+
// The body may span multiple TCP segments.
216+
// If Content-Length is present and larger than what we have so far,
217+
// buffer the partial body and wait for remaining chunks.
218+
const content_length = parseInt(req_headers.get("content-length") || "0", 10);
219+
if(content_length > 0 && bodyBytes.length < content_length)
220+
{
221+
const fetch_url = this.net.cors_proxy
222+
? this.net.cors_proxy + encodeURIComponent(target.href)
223+
: target.href;
224+
this._xb = {
225+
buf: bodyBytes,
226+
cl: content_length,
227+
done: (body) => {
228+
opts.body = body;
229+
dispatch_fetch(this, fetch_url, opts);
230+
},
231+
};
232+
return;
167233
}
168-
169-
const fetch_url = this.net.cors_proxy ? this.net.cors_proxy + encodeURIComponent(target.href) : target.href;
170-
this._dispatch_fetch(fetch_url, opts);
234+
opts.body = bodyBytes;
171235
}
236+
237+
const fetch_url = this.net.cors_proxy
238+
? this.net.cors_proxy + encodeURIComponent(target.href)
239+
: target.href;
240+
dispatch_fetch(this, fetch_url, opts);
172241
}
173242

174243
/**
175-
* @this {TCPConnection}
244+
* Execute the HTTP fetch and pipe the response back to the guest.
245+
*
246+
* @param {TCPConnection} conn
176247
* @param {string} fetch_url
177248
* @param {!Object} opts
178249
*/
179-
FetchNetworkAdapter.prototype._dispatch_fetch = function(fetch_url, opts)
250+
function dispatch_fetch(conn, fetch_url, opts)
180251
{
181-
const encoder = new TextEncoder();
182252
let response_started = false;
183253
let handler = (resp) => {
184254
let resp_headers = new Headers(resp.headers);
@@ -190,40 +260,47 @@ FetchNetworkAdapter.prototype._dispatch_fetch = function(fetch_url, opts)
190260
resp_headers.set("x-fetch-resp-url", resp.url);
191261
resp_headers.set("connection", "close");
192262

193-
this.write(this.net.form_response_head(resp.status, resp.statusText, resp_headers));
263+
conn.write(conn.net.form_response_head(resp.status, resp.statusText, resp_headers));
194264
response_started = true;
195265

196-
if(resp.body && resp.body.getReader) {
266+
if(resp.body && resp.body.getReader)
267+
{
197268
const resp_reader = resp.body.getReader();
198269
const pump = ({ value, done }) => {
199-
if(value) {
200-
this.write(value);
270+
if(value)
271+
{
272+
conn.write(value);
201273
}
202-
if(done) {
203-
this.close();
274+
if(done)
275+
{
276+
conn.close();
204277
}
205-
else {
278+
else
279+
{
206280
return resp_reader.read().then(pump);
207281
}
208282
};
209283
resp_reader.read().then(pump);
210-
} else {
284+
}
285+
else
286+
{
211287
resp.arrayBuffer().then(buffer => {
212-
this.write(new Uint8Array(buffer));
213-
this.close();
288+
conn.write(new Uint8Array(buffer));
289+
conn.close();
214290
});
215291
}
216292
};
217293

218-
this.net.fetch(fetch_url, opts).then(handler)
294+
conn.net.fetch(fetch_url, opts).then(handler)
219295
.catch((e) => {
220296
console.warn("Fetch Failed: " + fetch_url + "\n" + e);
221-
if(!response_started) {
222-
this.net.respond_text_and_close(this, 502, "Fetch Error", `Fetch ${fetch_url} failed:\n\n${e.stack || e.message}`);
297+
if(!response_started)
298+
{
299+
conn.net.respond_text_and_close(conn, 502, "Fetch Error", `Fetch ${fetch_url} failed:\n\n${e.stack || e.message}`);
223300
}
224-
this.close();
301+
conn.close();
225302
});
226-
};
303+
}
227304

228305
FetchNetworkAdapter.prototype.fetch = async function(url, options)
229306
{
@@ -244,7 +321,7 @@ FetchNetworkAdapter.prototype.fetch = async function(url, options)
244321
statusText: "Fetch Error",
245322
headers: new Headers({ "Content-Type": "text/plain" }),
246323
},
247-
new TextEncoder().encode(`Fetch ${url} failed:\n\n${e.stack}`).buffer
324+
textEncoder.encode(`Fetch ${url} failed:\n\n${e.stack}`).buffer
248325
];
249326
}
250327
};
@@ -255,11 +332,12 @@ FetchNetworkAdapter.prototype.form_response_head = function(status_code, status_
255332
`HTTP/1.1 ${status_code} ${status_text}`
256333
];
257334

258-
for(const [key, value] of headers.entries()) {
335+
for(const [key, value] of headers.entries())
336+
{
259337
lines.push(`${key}: ${value}`);
260338
}
261339

262-
return new TextEncoder().encode(lines.join("\r\n") + "\r\n\r\n");
340+
return textEncoder.encode(lines.join("\r\n") + "\r\n\r\n");
263341
};
264342

265343
FetchNetworkAdapter.prototype.respond_text_and_close = function(conn, status_code, status_text, body)
@@ -269,14 +347,15 @@ FetchNetworkAdapter.prototype.respond_text_and_close = function(conn, status_cod
269347
"content-length": body.length.toString(10),
270348
"connection": "close"
271349
});
272-
conn.writev([this.form_response_head(status_code, status_text, headers), new TextEncoder().encode(body)]);
350+
conn.writev([this.form_response_head(status_code, status_text, headers), textEncoder.encode(body)]);
273351
conn.close();
274352
};
275353

276354
FetchNetworkAdapter.prototype.parse_http_header = function(header)
277355
{
278356
const parts = header.match(/^([^:]*):(.*)$/);
279-
if(!parts) {
357+
if(!parts)
358+
{
280359
dbg_log("Unable to parse HTTP header", LOG_FETCH);
281360
return;
282361
}

0 commit comments

Comments
 (0)