Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/web.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,11 @@ const signWith = async (key, text) => toHex(await subtle.sign('HMAC', key, utf8.
*/
const copyOf = (data) => Uint8Array.prototype.slice.call(data);

/** @param {unknown} data */
const isBytes = (data) =>
ArrayBuffer.isView(data) && /** @type {any} */ (data).BYTES_PER_ELEMENT === 1; // a Uint8Array of any realm or a Node Buffer; not a DataView or a wider array

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Validate the view's actual element type · ref 850dd2

The check accepts any ArrayBuffer.isView(data) whose ordinary data.BYTES_PER_ELEMENT property equals 1.

Failure: A DataView can have its own BYTES_PER_ELEMENT: 1 and length: 3 properties. Passing such a view over bytes [1, 2, 3] succeeds and produces a correctly signed frame containing [0, 0, 0], because frameChunk treats the DataView as an array-like object with no indexed elements. The previous copy operation rejected it, and the signing chain now advances.

Suggested fix: Use an intrinsic typed-array brand check that accepts the one-byte typed-array kinds and rejects DataView and wider views, without trusting an overridable property.

const blankSignature = '0'.repeat(64); // a signature is 64 hex digits

/**
* Runs the signing steps of a stream one after the other. A step that throws rejects its own call only: the next one still runs, as on the
* synchronous entries, where a refused call throws and the signer stays usable. A step moves the chain only once it has succeeded.
Expand Down Expand Up @@ -326,8 +331,17 @@ export function chunkSigner(authHeaders, credentials) {
if (data.length === 0) {
return data;
}
const own = copyOf(data); // the signature and the frame are made when the chunk's turn comes; the caller may reuse its buffer once this returns
return frameChunk(await sign(own), own);
// The one copy of the bytes is the frame itself, made now, so the caller may reuse its buffer once this returns. The data is hashed where it sits in the
// frame and the signature is written into the blank the frame has for it, when the chunk's turn comes.
if (!isBytes(data)) {
throw new TypeError('data must be a Uint8Array');
}
const frame = frameChunk(blankSignature, data);
const body = frame.length - 2 - data.length; // the data ends the frame, followed by CR LF; the signature ends the line before it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Use one fixed length when framing a growable view · ref e8c2b2

frameChunk(blankSignature, data) reads data.length while building the frame, then body reads data.length again to locate the signature.

Failure: If another worker grows a SharedArrayBuffer while a length-tracking Uint8Array over it is being framed, those length reads can differ. The signature can then overwrite part of ;chunk-signature=, or the header can contain the wrong payload size, so the service rejects the upload. The previous implementation first copied into a fixed-length array.

Suggested fix: Capture the length once, create a fixed-length view for framing, and use that same length to calculate the body and signature offsets.

const at = body - 2 - blankSignature.length;
const signature = await sign(frame.subarray(body, frame.length - 2));
utf8.encodeInto(signature, frame.subarray(at, at + signature.length));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium: Avoid requiring encodeInto after advancing the chain · ref 80d188

await sign(...) updates previous, after which the code calls utf8.encodeInto(...) to finish the frame.

Failure: In a WebCrypto environment that provides TextEncoder.encode but lacks encodeInto, every nonempty chunk rejects with a TypeError. The rejected chunk has already advanced the chain, so a subsequent end() is also signed against bytes that were never sent.

Suggested fix: Write the ASCII signature directly or use the already-required encode method, and ensure a framing failure cannot commit the signature chain.

return frame;
},
async end(trailers) {
const text = trailers === undefined ? undefined : canonicalTrailers(trailers); // before sign(), so a refused trailer leaves the chain where it was
Expand Down
24 changes: 24 additions & 0 deletions test/hardening.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,30 @@ for (const [
assert.deepEqual(await pending, expected);
});

test(`${name}: a chunk that is a view into a larger buffer is signed as its own bytes`, async () => {
const whole = new Uint8Array(40).map((_, i) => i);
const part = whole.subarray(10, 30);
const expected = await chunkSigner(await chunkedHeaders(), credentials).chunk(part.slice());
assert.deepEqual(await chunkSigner(await chunkedHeaders(), credentials).chunk(part), expected);
assert.deepEqual(
await chunkSigner(await chunkedHeaders(), credentials).chunk(Buffer.from(part)),
expected,
);
});

// the synchronous entries hash whatever node:crypto or Bun accepts (a string, a wider typed array); only the WebCrypto entry checks the type before framing
test(
`${name}: a chunk that is not bytes is refused, even one that claims to be a Uint8Array`,
{ skip: name !== 'web' },
async () => {
const claims = { [Symbol.toStringTag]: 'Uint8Array', length: 3 };
const signer = chunkSigner(await chunkedHeaders(), credentials);
for (const data of [claims, new DataView(new ArrayBuffer(3)), new Uint16Array(3), 'abc']) {
await rejects(() => signer.chunk(/** @type {any} */ (data)), TypeError, /./);
}
},
);

test(`${name}: an event is signed as the caller passed it, even if the buffer is reused straight away`, async () => {
const buffer = new Uint8Array([1, 2, 3]);
const seed = () => getAuthHeaders({ ...request, service: 'transcribe' }, credentials);
Expand Down
Loading