Skip to content

Commit 777674d

Browse files
committed
Merge tag '1.7.5'
Fedify 1.7.5
2 parents eb39a7e + f013c93 commit 777674d

4 files changed

Lines changed: 124 additions & 22 deletions

File tree

CHANGES.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ To be released.
3030
- Added optional `KvStore.cas()` method.
3131
- Added `MemoryKvStore.cas()` method.
3232
- Added `DenoKvStore.cas()` method.
33-
33+
3434
- Added options to customize the temporary actor information when running
3535
`fedify inbox` command. [[#262], [#285] by Hasang Cho]
3636

@@ -77,6 +77,16 @@ To be released.
7777
[#285]: https://github.com/fedify-dev/fedify/pull/285
7878

7979

80+
Version 1.7.5
81+
-------------
82+
83+
Released on July 15, 2025.
84+
85+
- Fixed `TypeError: unusable` error that occurred when `doubleKnock()`
86+
encountered redirects during HTTP signature retry attempts.
87+
[[#294], [#295]]
88+
89+
8090
Version 1.7.4
8191
-------------
8292

@@ -150,6 +160,19 @@ Released on June 25, 2025.
150160
[#252]: https://github.com/fedify-dev/fedify/pull/252
151161

152162

163+
Version 1.6.6
164+
-------------
165+
166+
Released on July 15, 2025.
167+
168+
- Fixed `TypeError: unusable` error that occurred when `doubleKnock()`
169+
encountered redirects during HTTP signature retry attempts.
170+
[[#294], [#295]]
171+
172+
[#294]: https://github.com/fedify-dev/fedify/issues/294
173+
[#295]: https://github.com/fedify-dev/fedify/pull/295
174+
175+
153176
Version 1.6.5
154177
-------------
155178

docs/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,18 @@ Fedify docs
44
This directory contains the source files of the Fedify docs. The docs are
55
written in Markdown format and are built with [VitePress].
66

7-
In order to build the docs locally, you need to install [Bun]
8-
first. Then you can run the following commands (assuming you are in
7+
In order to build the docs locally, you need to install [Node.js] and [pnpm]
8+
first. Then you can run the following commands (assuming you are in
99
the *docs/* directory):
1010

1111
~~~~ bash
12-
bun install
13-
bun run dev
12+
pnpm install
13+
pnpm dev
1414
~~~~
1515

1616
Once the development server is running, you can open your browser and navigate
1717
to *http://localhost:5173/* to view the docs.
1818

1919
[VitePress]: https://vitepress.dev/
20-
[Bun]: https://bun.sh/
20+
[Node.js]: https://nodejs.org/
21+
[pnpm]: https://pnpm.io/

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)