UNPKG

@hpke/ohttp

Version:

Oblivious HTTP (RFC 9458) client and server using HPKE

311 lines (310 loc) • 10.3 kB
/** * Binary HTTP (RFC 9292) decoder. * * Both known-length and indeterminate-length messages are supported, * including truncated messages (Section 3.8) and padding (Section 3.8). * Trailers and informational (1xx) responses are validated but discarded since * they cannot be expressed with the Fetch API `Request`/`Response` objects. * * @module */ import { OhttpError } from "../errors.js"; import { readVarint } from "./varint.js"; const FRAMING_KNOWN_LENGTH_REQUEST = 0; const FRAMING_KNOWN_LENGTH_RESPONSE = 1; const FRAMING_INDETERMINATE_LENGTH_REQUEST = 2; const FRAMING_INDETERMINATE_LENGTH_RESPONSE = 3; /** Status codes for which the Fetch API requires a null body. */ const NULL_BODY_STATUSES = [204, 205, 304]; function invalid(reason) { return new OhttpError(`Invalid binary HTTP message: ${reason}`); } /** * Converts bytes to a ByteString (each byte maps to one code unit), which is * the representation used by Fetch API headers and methods. */ function bytesToByteString(b) { let s = ""; for (let i = 0; i < b.byteLength; i++) { s += String.fromCharCode(b[i]); } return s; } /** Sequential reader over a byte buffer. */ class Reader { constructor(buf) { Object.defineProperty(this, "_buf", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "_pos", { enumerable: true, configurable: true, writable: true, value: 0 }); this._buf = buf; } get atEnd() { return this._pos >= this._buf.byteLength; } varint() { const [v, next] = readVarint(this._buf, this._pos); this._pos = next; return v; } bytes(len) { if (len > this._buf.byteLength - this._pos) { throw invalid("truncated"); } const b = this._buf.subarray(this._pos, this._pos + len); this._pos += len; return b; } /** Reads a varint length prefix followed by that many bytes. */ lengthPrefixed() { return this.bytes(this.varint()); } /** Ensures all remaining bytes are zero padding. */ checkPadding() { for (; this._pos < this._buf.byteLength; this._pos++) { if (this._buf[this._pos] !== 0) { throw invalid("non-zero padding"); } } } } const SP = 0x20; const HTAB = 0x09; /** Non-alphanumeric `tchar` octets (RFC 9110 Section 5.6.2). */ const TCHAR_SYMBOLS = new Set([..."!#$%&'*+-.^_`|~"].map((c) => c.charCodeAt(0))); function isTchar(c) { return (c >= 0x30 && c <= 0x39) || // DIGIT (c >= 0x41 && c <= 0x5a) || // uppercase ALPHA (c >= 0x61 && c <= 0x7a) || // lowercase ALPHA TCHAR_SYMBOLS.has(c); } /** * Validates a field name. RFC 9292 Section 3.6 applies the HTTP field name * rules (a `token`, RFC 9110 Section 5.1), so uppercase letters are allowed; * the HTTP/2 lowercase requirement does not apply to Binary HTTP. * Pseudo-fields (e.g. ":path") are rejected since the control data carries * that information and `Headers` cannot represent them. */ function validateFieldName(name) { if (name.byteLength === 0) { throw invalid("empty field name"); } if (name[0] === 0x3a) { throw invalid("pseudo-field name is not allowed"); } for (const c of name) { if (!isTchar(c)) { throw invalid("invalid character in field name"); } } } /** * Validates a field value per RFC 9113 Section 8.2.1, which RFC 9292 * Section 3.6 applies to field values: it must not contain * NUL, LF or CR, and must not start or end with SP or HTAB. Validation is * performed on the raw octets since `Headers` silently strips such * whitespace. */ function validateFieldValue(value) { const len = value.byteLength; if (len === 0) { return; } for (const i of [0, len - 1]) { if (value[i] === SP || value[i] === HTAB) { throw invalid("leading or trailing whitespace in field value"); } } for (const c of value) { if (c === 0x00 || c === 0x0a || c === 0x0d) { throw invalid("invalid character in field value"); } } } /** * Builds a `Headers` from field lines. Multiple `cookie` field lines are * concatenated with "; " (RFC 9113 Section 8.2.3, RFC 9292 Section 3.6) * instead of the ", " used by `Headers.append()`. */ class FieldSectionBuilder { constructor() { Object.defineProperty(this, "_headers", { enumerable: true, configurable: true, writable: true, value: new Headers() }); Object.defineProperty(this, "_cookies", { enumerable: true, configurable: true, writable: true, value: [] }); } add(name, value) { validateFieldName(name); validateFieldValue(value); // Field names are case-insensitive (RFC 9110 Section 5.1). const n = bytesToByteString(name).toLowerCase(); const v = bytesToByteString(value); if (n === "cookie") { this._cookies.push(v); } else { this._headers.append(n, v); } } build() { if (this._cookies.length > 0) { this._headers.set("cookie", this._cookies.join("; ")); } return this._headers; } } /** * Reads a field section (header or trailer section). * A section omitted by truncation is treated as empty. */ function readFieldSection(r, knownLength) { const fields = new FieldSectionBuilder(); if (r.atEnd) { return fields.build(); } if (knownLength) { const section = new Reader(r.lengthPrefixed()); while (!section.atEnd) { fields.add(section.lengthPrefixed(), section.lengthPrefixed()); } return fields.build(); } // Indeterminate length: field lines terminated by a zero-length name. for (let nameLen = r.varint(); nameLen !== 0; nameLen = r.varint()) { fields.add(r.bytes(nameLen), r.lengthPrefixed()); } return fields.build(); } /** * Reads the content into a newly allocated buffer of exactly its size. * Content omitted by truncation is treated as empty. */ function readContent(r, knownLength) { if (r.atEnd) { return new Uint8Array(0); } if (knownLength) { return r.lengthPrefixed().slice(); } // Indeterminate length: chunks terminated by a zero-length chunk. const chunks = []; let total = 0; for (let len = r.varint(); len !== 0; len = r.varint()) { chunks.push(r.bytes(len)); total += len; } const content = new Uint8Array(total); let p = 0; for (const chunk of chunks) { content.set(chunk, p); p += chunk.byteLength; } return content; } /** Reads content, trailers and padding that follow the header section. */ function readRemainder(r, knownLength) { const content = readContent(r, knownLength); readFieldSection(r, knownLength); // trailers are discarded r.checkPadding(); return content; } /** Wraps non-OhttpError exceptions (e.g. TypeError from Fetch API objects). */ function wrapError(e) { if (e instanceof OhttpError) { return e; } return invalid(e instanceof Error ? e.message : String(e)); } /** * Decodes a Binary HTTP request into a `Request`. */ export function decodeRequest(src) { try { const r = new Reader(src); const framing = r.varint(); if (framing !== FRAMING_KNOWN_LENGTH_REQUEST && framing !== FRAMING_INDETERMINATE_LENGTH_REQUEST) { throw invalid(`unexpected framing indicator ${framing}`); } const knownLength = framing === FRAMING_KNOWN_LENGTH_REQUEST; // Request control data const method = bytesToByteString(r.lengthPrefixed()); const scheme = bytesToByteString(r.lengthPrefixed()); let authority = bytesToByteString(r.lengthPrefixed()); const path = bytesToByteString(r.lengthPrefixed()); const headers = readFieldSection(r, knownLength); const content = readRemainder(r, knownLength); if (authority === "") { authority = headers.get("host") ?? ""; } if (scheme === "" || authority === "") { throw invalid("missing scheme or authority"); } const hasNoBody = method === "GET" || method === "HEAD"; if (hasNoBody && content.byteLength > 0) { throw invalid(`${method} request with content is not supported`); } return new Request(`${scheme}://${authority}${path}`, { method, headers, body: hasNoBody || content.byteLength === 0 ? null : content.buffer, }); } catch (e) { throw wrapError(e); } } /** * Decodes a Binary HTTP response into a `Response`. */ export function decodeResponse(src) { try { const r = new Reader(src); const framing = r.varint(); if (framing !== FRAMING_KNOWN_LENGTH_RESPONSE && framing !== FRAMING_INDETERMINATE_LENGTH_RESPONSE) { throw invalid(`unexpected framing indicator ${framing}`); } const knownLength = framing === FRAMING_KNOWN_LENGTH_RESPONSE; // Informational responses (discarded) followed by the final status. let status = r.varint(); while (status >= 100 && status < 200) { readFieldSection(r, knownLength); status = r.varint(); } if (status < 200 || status > 599) { throw invalid(`invalid status code ${status}`); } const headers = readFieldSection(r, knownLength); const content = readRemainder(r, knownLength); const nullBody = NULL_BODY_STATUSES.includes(status); if (nullBody && content.byteLength > 0) { throw invalid(`status ${status} response with content`); } return new Response(nullBody ? null : content.buffer, { status, headers, }); } catch (e) { throw wrapError(e); } }