fix(tlon): release failed upload response bodies (#110442)

* fix(tlon): release failed upload response bodies

* fix(tlon): settle guarded upload responses

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Wynne668
2026-07-18 21:19:03 +01:00
committed by GitHub
co-authored by Peter Steinberger
parent a7e47653fc
commit 37b0b12eb7
2 changed files with 165 additions and 20 deletions
+137 -8
View File
@@ -55,14 +55,30 @@ function createMemexResponse(
);
}
function createGuardedResult(response: Response, finalUrl: string) {
function createGuardedResult(
response: Response,
finalUrl: string,
release: () => Promise<void> = mockRelease,
) {
return {
response,
finalUrl,
release: mockRelease,
release,
};
}
function responseWithCancelableBody(
status: number,
cancelBody: () => void | PromiseLike<void>,
): Response {
return new Response(
new ReadableStream<Uint8Array>({
cancel: cancelBody,
}),
{ status },
);
}
function guardedFetchCall(index: number): Parameters<typeof fetchWithSsrFGuard>[0] {
const call = mockGuardedFetch.mock.calls[index]?.at(0);
if (call === undefined) {
@@ -109,16 +125,16 @@ describe("uploadFile memex upload hardening", () => {
});
it("routes the memex upload URL through the SSRF guard", async () => {
const lookupResponse = createMemexResponse("https://uploads.tlon.network/put");
const lookupCancel = vi.spyOn(lookupResponse.body!, "cancel");
const uploadCancel = vi.fn();
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network/put"),
"https://memex.tlon.network/v1/zod/upload",
),
createGuardedResult(lookupResponse, "https://memex.tlon.network/v1/zod/upload"),
)
.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
responseWithCancelableBody(200, uploadCancel),
"https://uploads.tlon.network/put",
),
);
@@ -159,9 +175,40 @@ describe("uploadFile memex upload hardening", () => {
expect(secondCall?.maxRedirects).toBe(0);
expect(secondCall?.timeoutMs).toBe(300_000);
expect(secondCall?.init?.body).toBeInstanceOf(Blob);
expect(lookupCancel).not.toHaveBeenCalled();
expect(uploadCancel).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledTimes(2);
});
it("cancels failed Memex upload URL responses before releasing their guard", async () => {
const events: string[] = [];
const cancelBody = vi.fn(() => {
events.push("cancel");
});
const release = vi.fn(async () => {
events.push("release");
});
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
responseWithCancelableBody(500, cancelBody),
"https://memex.tlon.network/v1/zod/upload",
release,
),
);
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Memex upload request failed: 500");
expect(cancelBody).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
expect(events).toEqual(["cancel", "release"]);
});
it("surfaces guarded upload failures for hosted Memex targets", async () => {
mockGuardedFetch
.mockResolvedValueOnce(
@@ -190,6 +237,62 @@ describe("uploadFile memex upload hardening", () => {
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("cancels failed Memex upload responses before releasing their guard", async () => {
const cancelBody = vi.fn();
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network/put"),
"https://memex.tlon.network/v1/zod/upload",
),
)
.mockResolvedValueOnce(
createGuardedResult(
responseWithCancelableBody(500, cancelBody),
"https://uploads.tlon.network/put",
),
);
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Upload failed: 500");
expect(cancelBody).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledTimes(2);
});
it("cancels hosted upload responses when their final URL is untrusted", async () => {
const cancelBody = vi.fn();
mockGuardedFetch
.mockResolvedValueOnce(
createGuardedResult(
createMemexResponse("https://uploads.tlon.network/put"),
"https://memex.tlon.network/v1/zod/upload",
),
)
.mockResolvedValueOnce(
createGuardedResult(
responseWithCancelableBody(200, cancelBody),
"https://evil.example/put",
),
);
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Memex final upload URL must target a trusted hosted Tlon domain");
expect(cancelBody).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledTimes(2);
});
it("rejects Memex upload targets outside the hosted Tlon domain allowlist", async () => {
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
@@ -483,10 +586,13 @@ describe("uploadFile custom S3 upload hardening", () => {
});
it("routes the custom S3 signed URL through the SSRF guard", async () => {
const cancelBody = vi.fn(async () => {
throw new Error("stream cancellation failed");
});
mockGetSignedUrl.mockResolvedValueOnce("https://s3.example.com/uploads/file?sig=abc");
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
new Response(null, { status: 200 }),
responseWithCancelableBody(200, cancelBody),
"https://s3.example.com/uploads/file?sig=abc",
),
);
@@ -513,6 +619,7 @@ describe("uploadFile custom S3 upload hardening", () => {
expect(uploadCall?.maxRedirects).toBe(0);
expect(uploadCall?.timeoutMs).toBe(300_000);
expect(uploadCall?.policy).toBeUndefined();
expect(cancelBody).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledTimes(1);
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
});
@@ -534,6 +641,28 @@ describe("uploadFile custom S3 upload hardening", () => {
expect(vi.mocked(globalThis.fetch)).not.toHaveBeenCalled();
});
it("cancels failed custom S3 upload responses before releasing their guard", async () => {
const cancelBody = vi.fn();
mockGetSignedUrl.mockResolvedValueOnce("https://s3.example.com/uploads/file?sig=abc");
mockGuardedFetch.mockResolvedValueOnce(
createGuardedResult(
responseWithCancelableBody(500, cancelBody),
"https://s3.example.com/uploads/file?sig=abc",
),
);
await expect(
uploadFile({
blob: new Blob(["image-bytes"], { type: "image/png" }),
fileName: "avatar.png",
contentType: "image/png",
}),
).rejects.toThrow("Upload failed: 500");
expect(cancelBody).toHaveBeenCalledTimes(1);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("passes the private-network opt-in to guarded custom S3 uploads", async () => {
configureClient({
shipUrl: "https://ship.example.com",
+28 -12
View File
@@ -58,6 +58,25 @@ const TLON_UPLOAD_TIMEOUT_MS = 300_000;
let currentClientConfig: ClientConfig | null = null;
async function releaseUploadResponse(
guarded: Awaited<ReturnType<typeof fetchWithSsrFGuard>> | undefined,
): Promise<void> {
if (!guarded) {
return;
}
try {
// Guard release closes the dispatcher, not an unread response stream. Settle terminal
// upload bodies first so a streaming response cannot delay dispatcher cleanup.
if (!guarded.response.bodyUsed) {
await guarded.response.body?.cancel();
}
} catch {
// Response cancellation is best-effort; dispatcher release must still run.
} finally {
await guarded.release();
}
}
export function configureClient(params: ClientConfig): void {
currentClientConfig = {
...params,
@@ -240,9 +259,9 @@ async function getMemexUploadUrl(params: {
}
const endpoint = `${MEMEX_BASE_URL}/v1/${params.config.shipName}/upload`;
let release: (() => Promise<void>) | undefined;
let guarded: Awaited<ReturnType<typeof fetchWithSsrFGuard>> | undefined;
try {
const guarded = await fetchWithSsrFGuard({
guarded = await fetchWithSsrFGuard({
url: endpoint,
init: {
method: "PUT",
@@ -259,7 +278,6 @@ async function getMemexUploadUrl(params: {
maxRedirects: 0,
timeoutMs: TLON_MEMEX_UPLOAD_URL_TIMEOUT_MS,
});
release = guarded.release;
if (!guarded.response.ok) {
throw new Error(`Memex upload request failed: ${guarded.response.status}`);
}
@@ -275,7 +293,7 @@ async function getMemexUploadUrl(params: {
return { hostedUrl: data.filePath, uploadUrl: data.url };
} finally {
await release?.();
await releaseUploadResponse(guarded);
}
}
@@ -310,9 +328,9 @@ export async function uploadFile(params: UploadFileParams): Promise<UploadResult
});
const trustedUploadUrl = assertTrustedMemexUploadUrl(uploadUrl, "Memex upload URL");
let release: (() => Promise<void>) | undefined;
let guarded: Awaited<ReturnType<typeof fetchWithSsrFGuard>> | undefined;
try {
const guarded = await fetchWithSsrFGuard({
guarded = await fetchWithSsrFGuard({
url: trustedUploadUrl,
init: {
method: "PUT",
@@ -327,13 +345,12 @@ export async function uploadFile(params: UploadFileParams): Promise<UploadResult
maxRedirects: 0,
timeoutMs: TLON_UPLOAD_TIMEOUT_MS,
});
release = guarded.release;
assertTrustedMemexUploadUrl(guarded.finalUrl, "Memex final upload URL");
if (!guarded.response.ok) {
throw new Error(`Upload failed: ${guarded.response.status}`);
}
} finally {
await release?.();
await releaseUploadResponse(guarded);
}
return { url: assertTrustedMemexUploadUrl(hostedUrl, "Memex hosted URL") };
@@ -371,9 +388,9 @@ export async function uploadFile(params: UploadFileParams): Promise<UploadResult
expiresIn: 3600,
});
let release: (() => Promise<void>) | undefined;
let guarded: Awaited<ReturnType<typeof fetchWithSsrFGuard>> | undefined;
try {
const guarded = await fetchWithSsrFGuard({
guarded = await fetchWithSsrFGuard({
url: signedUrl,
init: {
method: "PUT",
@@ -386,12 +403,11 @@ export async function uploadFile(params: UploadFileParams): Promise<UploadResult
policy: privateNetworkPolicy,
timeoutMs: TLON_UPLOAD_TIMEOUT_MS,
});
release = guarded.release;
if (!guarded.response.ok) {
throw new Error(`Upload failed: ${guarded.response.status}`);
}
} finally {
await release?.();
await releaseUploadResponse(guarded);
}
const publicUrl = storageConfig.publicUrlBase