Skip to content

Commit 158c379

Browse files
committed
Merge tag '1.6.6' into 1.7-maintenance
Fedify 1.6.6
2 parents b7d950c + 5f994dc commit 158c379

3 files changed

Lines changed: 111 additions & 16 deletions

File tree

CHANGES.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Version 1.7.5
88

99
To be released.
1010

11+
- Fixed `TypeError: unusable` error that occurred when `doubleKnock()`
12+
encountered redirects during HTTP signature retry attempts.
13+
[[#294], [#295]]
14+
1115

1216
Version 1.7.4
1317
-------------
@@ -82,6 +86,19 @@ Released on June 25, 2025.
8286
[#252]: https://github.com/fedify-dev/fedify/pull/252
8387

8488

89+
Version 1.6.6
90+
-------------
91+
92+
Released on July 15, 2025.
93+
94+
- Fixed `TypeError: unusable` error that occurred when `doubleKnock()`
95+
encountered redirects during HTTP signature retry attempts.
96+
[[#294], [#295]]
97+
98+
[#294]: https://github.com/fedify-dev/fedify/issues/294
99+
[#295]: https://github.com/fedify-dev/fedify/pull/295
100+
101+
85102
Version 1.6.5
86103
-------------
87104

fedify/sig/http.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1877,3 +1877,61 @@ test("verifyRequest() [rfc9421] error handling for invalid signature base creati
18771877
"Verification should fail gracefully for malformed signature inputs",
18781878
);
18791879
});
1880+
1881+
test("doubleKnock() regression test for TypeError: unusable bug #294", async () => {
1882+
// This test reproduces the bug where request.clone().body in the second redirect
1883+
// handling path causes "TypeError: unusable" when the body is consumed before
1884+
// subsequent clone() calls in signRequest functions.
1885+
1886+
fetchMock.spyGlobal();
1887+
1888+
let requestCount = 0;
1889+
1890+
// Mock server that:
1891+
// 1. Returns 401 for first spec (triggers retry with different spec)
1892+
// 2. Returns 302 redirect for second spec (triggers redirect handling)
1893+
// 3. Returns 200 for final destination
1894+
fetchMock.post("https://example.com/inbox-retry-redirect", (_cl) => {
1895+
requestCount++;
1896+
1897+
if (requestCount === 1) {
1898+
// First request: reject to trigger retry with different spec
1899+
return new Response("Unauthorized", { status: 401 });
1900+
} else if (requestCount === 2) {
1901+
// Second request: redirect to trigger the problematic redirect handling
1902+
return Response.redirect("https://example.com/final-destination", 302);
1903+
}
1904+
1905+
return new Response("Should not reach here", { status: 500 });
1906+
});
1907+
1908+
// Mock final destination
1909+
fetchMock.post("https://example.com/final-destination", () => {
1910+
return new Response("Success", { status: 200 });
1911+
});
1912+
1913+
const request = new Request("https://example.com/inbox-retry-redirect", {
1914+
method: "POST",
1915+
body: "Test activity content",
1916+
headers: {
1917+
"Content-Type": "application/activity+json",
1918+
},
1919+
});
1920+
1921+
// This should trigger the bug: 401 -> retry -> 302 -> TypeError: unusable
1922+
// because the second redirect path uses request.clone().body instead of
1923+
// await request.clone().arrayBuffer()
1924+
const response = await doubleKnock(
1925+
request,
1926+
{
1927+
keyId: rsaPublicKey2.id!,
1928+
privateKey: rsaPrivateKey2,
1929+
},
1930+
);
1931+
1932+
// The test should pass after the fix
1933+
assertEquals(response.status, 200);
1934+
assertEquals(requestCount, 2, "Should make 2 requests before redirect");
1935+
1936+
fetchMock.hardReset();
1937+
});

fedify/sig/http.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,34 @@ export interface DoubleKnockOptions {
12211221
tracerProvider?: TracerProvider;
12221222
}
12231223

1224+
/**
1225+
* Helper function to create a new Request for redirect handling.
1226+
* @param request The original request.
1227+
* @param location The redirect location.
1228+
* @param body The request body as ArrayBuffer or undefined.
1229+
* @returns A new Request object for the redirect.
1230+
*/
1231+
function createRedirectRequest(
1232+
request: Request,
1233+
location: string,
1234+
body: ArrayBuffer | undefined,
1235+
): Request {
1236+
return new Request(location, {
1237+
method: request.method,
1238+
headers: request.headers,
1239+
body,
1240+
redirect: "manual",
1241+
signal: request.signal,
1242+
mode: request.mode,
1243+
credentials: request.credentials,
1244+
referrer: request.referrer,
1245+
referrerPolicy: request.referrerPolicy,
1246+
integrity: request.integrity,
1247+
keepalive: request.keepalive,
1248+
cache: request.cache,
1249+
});
1250+
}
1251+
12241252
/**
12251253
* Performs a double-knock request to the given URL. For the details of
12261254
* double-knocking, see
@@ -1264,19 +1292,7 @@ export async function doubleKnock(
12641292
? await request.clone().arrayBuffer()
12651293
: undefined;
12661294
return doubleKnock(
1267-
new Request(location, {
1268-
method: request.method,
1269-
headers: request.headers,
1270-
body,
1271-
redirect: "manual",
1272-
signal: request.signal,
1273-
mode: request.mode,
1274-
credentials: request.credentials,
1275-
referrer: request.referrer,
1276-
referrerPolicy: request.referrerPolicy,
1277-
integrity: request.integrity,
1278-
keepalive: request.keepalive,
1279-
}),
1295+
createRedirectRequest(request, location, body),
12801296
identity,
12811297
options,
12821298
);
@@ -1322,11 +1338,15 @@ export async function doubleKnock(
13221338
response.headers.has("Location")
13231339
) {
13241340
const location = response.headers.get("Location")!;
1341+
// IMPORTANT: Use arrayBuffer() instead of .body to prevent "TypeError: unusable"
1342+
// When using .body (ReadableStream), subsequent clone() calls in signRequest functions
1343+
// will fail because the stream has already been consumed. Using arrayBuffer() ensures
1344+
// the body can be safely cloned for HTTP signature generation.
13251345
const body = request.method !== "GET" && request.method !== "HEAD"
1326-
? request.clone().body
1327-
: null;
1346+
? await request.clone().arrayBuffer()
1347+
: undefined;
13281348
return doubleKnock(
1329-
new Request(location, { ...request, body }),
1349+
createRedirectRequest(request, location, body),
13301350
identity,
13311351
options,
13321352
);

0 commit comments

Comments
 (0)