Decode string storage within its length in Buffer encodings

String storage can hold bytes that are not well-formed UTF-8: an HTTP
header value keeps the raw bytes a client sent. Encoding such a string
read past its end.

- scr_bytes_next_cp decoded any high byte as the lead of a sequence and
  read up to three bytes after it without a bound. It now takes the
  storage length, decodes only complete, well-formed sequences, and
  returns any other byte as U+0000..U+00FF, advancing by one. That is
  each such byte's value in Node, which decodes header values as latin1.
  This covers latin1, ascii, binary and utf16le encoding, hex and base64
  of non-ASCII text (which narrow through latin1), Buffer#write and
  TextEncoder#encodeInto.
- base64 decoding read one byte past its input when the eight-character
  groups ended exactly at the end.

The runtime's bytes unit test (built with ASan) encodes ill-formed
storage at every offset within a word and random high bytes, and
compares latin1, ascii and utf16le with a scalar reference. A corpus
program sends such header bytes to a server and encodes them as Node
does.
This commit is contained in:
Chris Tate
2026-10-25 12:48:21 -05:00
parent 8a22e0f515
commit e24ed12933
5 changed files with 235 additions and 28 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/8010-http-header-raw-bytes.ts"
],
"diags": []
}
+26 -28
View File
@@ -2240,29 +2240,26 @@ ScrStr *scr_text_decode_stream(ScrDyn *state, const ScrBytes *input,
return result;
}
/* Decode the next code point from an ScrStr's storage — ALWAYS valid
* UTF-8 (the string runtime never stores ill-formed sequences), so no
* validation re-runs here. */
static uint32_t scr_bytes_next_cp(const uint8_t *in, size_t *ip) {
uint8_t b = in[*ip];
if (b < 0x80) {
*ip += 1;
return b;
}
if ((b & 0xe0) == 0xc0) {
uint32_t cp = ((uint32_t)(b & 0x1f) << 6) | (in[*ip + 1] & 0x3f);
*ip += 2;
return cp;
}
if ((b & 0xf0) == 0xe0) {
uint32_t cp = ((uint32_t)(b & 0xf) << 12) | ((uint32_t)(in[*ip + 1] & 0x3f) << 6) |
(in[*ip + 2] & 0x3f);
*ip += 3;
return cp;
}
uint32_t cp = ((uint32_t)(b & 0x7) << 18) | ((uint32_t)(in[*ip + 1] & 0x3f) << 12) |
((uint32_t)(in[*ip + 2] & 0x3f) << 6) | (in[*ip + 3] & 0x3f);
*ip += 4;
/* Decode the next code point from the n bytes of an ScrStr's storage.
* Storage is WTF-8, except where raw outside bytes become a string
* unchecked (HTTP header values). A byte that does not start a complete,
* well-formed sequence stands for U+0000..U+00FF, as each byte of such a
* header does in Node, and advances by one; nothing is read past n. */
static uint32_t scr_bytes_next_cp(const uint8_t *in, size_t n, size_t *ip) {
size_t i = *ip;
uint8_t b = in[i];
*ip = i + 1;
if (b < 0x80) return b;
size_t len = b >= 0xc2 && b <= 0xdf ? 2 : b >= 0xe0 && b <= 0xef ? 3 : b >= 0xf0 && b <= 0xf4 ? 4 : 0;
if (len == 0 || n - i < len) return b;
uint8_t lo = b == 0xe0 ? 0xa0 : b == 0xf0 ? 0x90 : 0x80;
uint8_t hi = b == 0xf4 ? 0x8f : 0xbf;
if (in[i + 1] < lo || in[i + 1] > hi) return b;
for (size_t k = 2; k < len; k++)
if ((in[i + k] & 0xc0) != 0x80) return b;
uint32_t cp = b & (0x7f >> len);
for (size_t k = 1; k < len; k++) cp = (cp << 6) | (in[i + k] & 0x3f);
*ip = i + len;
return cp;
}
@@ -2287,7 +2284,7 @@ ScrDyn *scr_text_encode_into(const ScrDyn *source, const ScrDyn *destination) {
read += ascii;
if (written == limit) break;
size_t end = written;
uint32_t cp = scr_bytes_next_cp((const uint8_t *)text->data, &end);
uint32_t cp = scr_bytes_next_cp((const uint8_t *)text->data, text->len, &end);
if (end > dest->len) break;
read += cp > 0xffff ? 2 : 1;
written = end;
@@ -2440,7 +2437,7 @@ ScrBytes *scr_buffer_transcode(const ScrDyn *source, const ScrDyn *from, const S
}
ScrStr *text = scr_str_from_utf8_lossy(input->data, len);
for (size_t i = 0; i < text->len;) {
uint32_t cp = scr_bytes_next_cp((const uint8_t *)text->data, &i);
uint32_t cp = scr_bytes_next_cp((const uint8_t *)text->data, text->len, &i);
written = scr_transcode_put(result->data, written, cp, to_encoding);
}
scr_str_release(text);
@@ -2774,6 +2771,7 @@ static ScrBytes *scr_bytes_decode_b64(ScrBytesSink *sink, const uint8_t *in, siz
o += 6;
i += 8;
}
if (i == n) break; /* the groups ended the input */
}
/* Common complete groups need neither per-character branches nor state. */
if (have == 0 && n - i >= 4) {
@@ -2824,7 +2822,7 @@ static ScrBytes *scr_bytes_encode_str(ScrBytesSink *sink, const ScrStr *s, const
size_t o = s->ascii ? n : scr_bytes_ascii_prefix(in, n), i = o;
memcpy(b->data, in, o);
while (i < n) {
uint32_t cp = scr_bytes_next_cp(in, &i);
uint32_t cp = scr_bytes_next_cp(in, n, &i);
if (cp > 0xffff) {
cp -= 0x10000;
b->data[o++] = (uint8_t)(0xd800 + (cp >> 10));
@@ -2841,7 +2839,7 @@ static ScrBytes *scr_bytes_encode_str(ScrBytesSink *sink, const ScrStr *s, const
ScrBytes *b = scr_bytes_conversion_storage_in(sink, n * 2);
size_t o = 0, i = 0;
while (i < n) {
uint32_t cp = scr_bytes_next_cp(in, &i);
uint32_t cp = scr_bytes_next_cp(in, n, &i);
if (cp > 0xffff) {
cp -= 0x10000;
uint32_t hi = 0xd800 + (cp >> 10), lo = 0xdc00 + (cp & 0x3ff);
@@ -3745,7 +3743,7 @@ double scr_bytes_write_str(ScrBytes *b, const ScrStr *s, const ScrStr *enc,
continue;
}
}
uint32_t cp = scr_bytes_next_cp((const uint8_t *)s->data, &i);
uint32_t cp = scr_bytes_next_cp((const uint8_t *)s->data, s->len, &i);
uint32_t first = cp, second = 0;
if (cp > 0xffff) {
cp -= 0x10000;
+130
View File
@@ -216,6 +216,135 @@ static void test_encodings(void) {
scr_str_release(b64);
}
/* The length of the well-formed WTF-8 sequence at in[i], or 0. */
static size_t ref_seq_len(const uint8_t *in, size_t n, size_t i) {
uint8_t b = in[i];
if (b < 0x80) return 1;
size_t len;
uint8_t lo = 0x80, hi = 0xbf;
if (b >= 0xc2 && b <= 0xdf) len = 2;
else if (b >= 0xe0 && b <= 0xef) len = 3;
else if (b >= 0xf0 && b <= 0xf4) len = 4;
else return 0;
if (b == 0xe0) lo = 0xa0;
if (b == 0xf0) lo = 0x90;
if (b == 0xf4) hi = 0x8f;
if (n - i < len || in[i + 1] < lo || in[i + 1] > hi) return 0;
for (size_t k = 2; k < len; k++)
if ((in[i + k] & 0xc0) != 0x80) return 0;
return len;
}
/* The UTF-16 units of string storage, one at a time: a well-formed
* sequence gives its code point's units and any other byte stands for
* U+0000..U+00FF (a raw HTTP header byte). */
static size_t ref_units(const uint8_t *in, size_t n, uint16_t *out) {
size_t k = 0;
for (size_t i = 0; i < n;) {
size_t len = ref_seq_len(in, n, i);
if (len == 0) {
out[k++] = in[i++];
continue;
}
uint32_t cp = len == 1 ? in[i] : (uint32_t)(in[i] & (0x7f >> len));
for (size_t j = 1; j < len; j++) cp = (cp << 6) | (in[i + j] & 0x3f);
i += len;
if (cp > 0xffff) {
out[k++] = (uint16_t)(0xd800 + ((cp - 0x10000) >> 10));
out[k++] = (uint16_t)(0xdc00 + ((cp - 0x10000) & 0x3ff));
} else {
out[k++] = (uint16_t)cp;
}
}
return k;
}
/* Encodes storage that is not well-formed (raw header bytes become string
* storage unchecked) and compares latin1, ascii and utf16le with the
* reference; hex and base64 decode the latin1 bytes. ASan checks that no
* path reads or writes outside either buffer. */
/* encs: latin1, ascii, utf16le, hex, base64. */
static void check_raw_encodings(ScrStr *const *encs, const uint8_t *in, size_t n, const char *what) {
ScrStr *latin1 = encs[0], *ascii = encs[1], *utf16le = encs[2], *hex = encs[3], *b64 = encs[4];
ScrStr *s = scr_str_new((const char *)in, n);
uint16_t units[64];
size_t k = ref_units(in, n, units);
ScrStr *narrow[] = {latin1, ascii};
for (size_t e = 0; e < 2; e++) {
ScrBytes *b = scr_bytes_from_str(s, narrow[e]);
bool ok = b->len == k;
for (size_t j = 0; ok && j < k; j++) ok = b->data[j] == (uint8_t)units[j];
check(ok, what);
scr_bytes_release(b);
}
ScrBytes *wide = scr_bytes_from_str(s, utf16le);
bool ok = wide->len == 2 * k;
for (size_t j = 0; ok && j < k; j++)
ok = wide->data[2 * j] == (uint8_t)units[j] && wide->data[2 * j + 1] == units[j] >> 8;
check(ok, what);
scr_bytes_release(wide);
scr_bytes_release(scr_bytes_from_str(s, hex));
scr_bytes_release(scr_bytes_from_str(s, b64));
scr_str_release(s);
}
static void test_raw_storage_encodings(void) {
/* Each pattern at every offset in an eight-byte word, alone and followed
* by ASCII, two-byte characters or more of the pattern. */
static const struct { const char *bytes; size_t n; } patterns[] = {
{"\x80", 1}, {"\x80\x81\xbf\x90", 4}, {"\xc3", 1}, {"\xc3\xa9", 2},
{"\xc3\xa9\xc3", 3}, {"\xc0\x80\xc1\xbf", 4}, {"\xe9\x41\xe9", 3},
{"\xf0\x9f\x41", 3}, {"\xe2\x82", 2}, {"\xe2\x82\xac", 3},
{"\xf0\x9f\x98\x80", 4}, {"\xed\xa0\x80", 3}, {"\xff\xfe\xf5", 3},
{"\xdf\xbf\x80\x80", 4}, {"\xc2\x80\xc2", 3}, {"x\xe9y\x80\x80z\xdf", 7},
};
static const struct { const char *bytes; size_t n; } tails[] = {
{"", 0}, {"bcdefghij", 9}, {"\xc3\xa9\xc3\xa9\xc3\xa9\xc3\xa9", 8}, {"\x80\xc3\xa9", 3},
};
ScrStr *encs[] = {S("latin1"), S("ascii"), S("utf16le"), S("hex"), S("base64")};
uint8_t buf[64];
char what[96];
for (size_t p = 0; p < sizeof patterns / sizeof patterns[0]; p++) {
for (size_t offset = 0; offset < 17; offset++) {
for (size_t t = 0; t < sizeof tails / sizeof tails[0]; t++) {
for (size_t lead = 0; lead < 2; lead++) {
/* lead: the offset is two-byte characters instead of ASCII. */
size_t n = 0;
for (size_t j = 0; j < offset; j++) {
if (lead && j + 1 < offset) {
buf[n++] = 0xc3;
buf[n++] = 0xa9;
j++;
} else {
buf[n++] = 'a';
}
}
memcpy(buf + n, patterns[p].bytes, patterns[p].n);
n += patterns[p].n;
memcpy(buf + n, tails[t].bytes, tails[t].n);
n += tails[t].n;
snprintf(what, sizeof what, "raw storage encodings: pattern %zu offset %zu tail %zu lead %zu",
p, offset, t, lead);
check_raw_encodings(encs, buf, n, what);
}
}
}
}
/* Random bytes, mostly high, of every length up to 40. */
uint32_t seed = 12345;
for (int round = 0; round < 4000; round++) {
size_t n = (size_t)round % 41;
for (size_t j = 0; j < n; j++) {
seed = seed * 1103515245u + 12345u;
uint8_t r = (uint8_t)(seed >> 16);
buf[j] = (seed >> 27) & 1 ? (uint8_t)(0x80 | r) : (uint8_t)(r & 0x7f);
}
snprintf(what, sizeof what, "raw storage encodings: random round %d", round);
check_raw_encodings(encs, buf, n, what);
}
for (size_t e = 0; e < sizeof encs / sizeof encs[0]; e++) scr_str_release(encs[e]);
}
static void test_concat_and_u32be(void) {
ScrArr *list = scr_arr_new(SCR_ELEM_BYTES, 0);
ScrBytes *a = scr_bytes_new(SCR_BYTES_U8, 2);
@@ -445,6 +574,7 @@ int main(int argc, char **argv) {
scr_bytes_test_bulk_storage();
scr_bytes_test_buffer_pool();
test_encodings();
test_raw_storage_encodings();
test_concat_and_u32be();
test_from_arr_and_random();
test_zlib_roundtrip();
@@ -0,0 +1,72 @@
// Header values hold the raw bytes a client sent, one character per byte
// (Node decodes them as latin1). Bytes that are not well-formed UTF-8 (lone
// continuation bytes, runs of them, truncated leads, overlong leads and
// bytes no sequence uses) at every offset within an eight-byte word encode
// to the same bytes as in Node with latin1, ascii, binary, hex, base64 and
// utf16le. Strings built in the program take the same encodings with two-,
// three- and four-byte characters at every offset.
import { createServer } from "node:http";
import { connect } from "node:net";
const patterns: number[][] = [
[0x80],
[0x80, 0x81, 0xbf, 0x90],
[0xc3],
[0xc0, 0x80, 0xc1, 0xbf],
[0xe9, 0x41, 0xe9],
[0xf0, 0x9f, 0x41],
[0xe2, 0x82],
[0xff, 0xfe, 0xf5],
[0x78, 0xe9, 0x79, 0x80, 0x80, 0x7a, 0xdf],
];
const encodings = ["latin1", "ascii", "binary", "hex", "base64", "utf16le"] as const;
function hex(bytes: Buffer): string {
return bytes.toString("hex");
}
function describe(label: string, value: string): void {
console.log(label, encodings.map((encoding) => `${encoding}=${hex(Buffer.from(value, encoding))}`).join(" "));
}
const names: string[] = [];
const head: Buffer[] = [Buffer.from("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n")];
for (let p = 0; p < patterns.length; p++) {
for (let offset = 0; offset < 10; offset++) {
for (const tail of ["", "bcdefghij"]) {
const name = `x-${p}-${offset}-${tail.length}`;
names.push(name);
head.push(Buffer.from(`${name}: ${"a".repeat(offset)}`));
head.push(Buffer.from(patterns[p]!));
head.push(Buffer.from(`${tail}\r\n`));
}
}
}
head.push(Buffer.from("\r\n"));
const server = createServer((req, res) => {
for (const name of names) {
const value = req.headers[name];
if (typeof value === "string") describe(name, value);
else console.log(name, "missing");
}
res.end("ok");
});
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address !== null ? address.port : 0;
const socket = connect({ port, host: "127.0.0.1" }, () => {
socket.write(Buffer.concat(head));
});
socket.on("data", () => {});
socket.on("close", () => {
server.close();
for (const text of ["é", "éü", "߀", "😀", "é😀ü", "߿\u0080"]) {
for (let offset = 0; offset < 17; offset++) {
const value = "a".repeat(offset) + text + "b".repeat(offset % 9) + text;
describe(`text ${offset} ${JSON.stringify(text)}`, value);
}
}
});
});
@@ -0,0 +1 @@
{ "plain": 3468, "san": 7463 }