diff --git a/src/media/store.redirect.test.ts b/src/media/store.redirect.test.ts index 9f23a7b56057..d9b9cf615848 100644 --- a/src/media/store.redirect.test.ts +++ b/src/media/store.redirect.test.ts @@ -17,6 +17,9 @@ function createMockHttpExchange() { statusCode: 0, headers: {} as Record, }); + const originalResume = res.resume.bind(res); + const resume = vi.fn(() => originalResume()); + res.resume = resume as typeof res.resume; const req = { on: (event: string, handler: (...args: unknown[]) => void) => { if (event === "error") { @@ -27,18 +30,40 @@ function createMockHttpExchange() { end: () => undefined, destroy: () => res.destroy(), } as const; - return { req, res }; + return { req, res, resume }; } -function mockRedirectExchange(params: { location?: string }) { - const { req, res } = createMockHttpExchange(); +function mockRedirectExchange(params: { body?: string; location?: string }) { + const { req, res, resume } = createMockHttpExchange(); res.statusCode = 302; res.headers = params.location ? { location: params.location } : {}; return { req, + resume, send(cb: (value: unknown) => void) { setImmediate(() => { cb(res as unknown); + if (params.body) { + res.write(params.body); + } + res.end(); + }); + }, + }; +} + +function mockHttpStatusExchange(params: { body?: string; statusCode: number }) { + const { req, res, resume } = createMockHttpExchange(); + res.statusCode = params.statusCode; + return { + req, + resume, + send(cb: (value: unknown) => void) { + setImmediate(() => { + cb(res as unknown); + if (params.body) { + res.write(params.body); + } res.end(); }); }, @@ -209,21 +234,74 @@ describe("media store redirects", () => { expect(getRequestHeaders(1).get("authorization")).toBe("Bearer secret"); }); + it("drains ignored redirect response bodies before following redirects", async () => { + let redirectResume: ReturnType | undefined; + let call = 0; + mockRequest.mockImplementation((_url, _opts, cb) => { + call += 1; + if (call === 1) { + const exchange = mockRedirectExchange({ + body: "ignored redirect response body", + location: "https://example.com/final", + }); + redirectResume = exchange.resume; + exchange.send(cb); + return exchange.req; + } + + const exchange = mockSuccessfulTextExchange({ + text: "redirected", + contentType: "text/plain", + }); + exchange.send(cb); + return exchange.req; + }); + + await saveMediaSource("https://example.com/start"); + + expect(redirectResume).toHaveBeenCalledOnce(); + }); + it("fails when redirect response omits location header", async () => { + let redirectResume: ReturnType | undefined; mockRequest.mockImplementationOnce((_url, _opts, cb) => { - const exchange = mockRedirectExchange({}); + const exchange = mockRedirectExchange({ body: "ignored redirect response body" }); + redirectResume = exchange.resume; exchange.send(cb); return exchange.req; }); await expectRedirectSaveFailure("Redirect loop or missing Location header"); + expect(redirectResume).toHaveBeenCalledOnce(); }); it("fails when redirect location is malformed", async () => { + let redirectResume: ReturnType | undefined; mockRequest.mockImplementationOnce((_url, _opts, cb) => { - const exchange = mockRedirectExchange({ location: "http://[" }); + const exchange = mockRedirectExchange({ + body: "ignored redirect response body", + location: "http://[", + }); + redirectResume = exchange.resume; exchange.send(cb); return exchange.req; }); await expectRedirectSaveFailure("Invalid redirect Location header"); + expect(redirectResume).toHaveBeenCalledOnce(); + }); + + it("drains ignored HTTP error response bodies before failing", async () => { + let errorResume: ReturnType | undefined; + mockRequest.mockImplementationOnce((_url, _opts, cb) => { + const exchange = mockHttpStatusExchange({ + body: "ignored error response body", + statusCode: 500, + }); + errorResume = exchange.resume; + exchange.send(cb); + return exchange.req; + }); + + await expectRedirectSaveFailure("HTTP 500 downloading media"); + expect(errorResume).toHaveBeenCalledOnce(); }); }); diff --git a/src/media/store.ts b/src/media/store.ts index 55a9d3f5387e..caa65757494f 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -198,6 +198,10 @@ function looksLikeUrl(src: string) { return /^https?:\/\//i.test(src); } +function discardIgnoredHttpResponse(res: NodeJS.ReadableStream): void { + res.resume(); +} + /** * Download media to disk while capturing the first few KB for mime sniffing. */ @@ -227,6 +231,7 @@ async function downloadToFile( if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { const location = res.headers.location; if (!location || maxRedirects <= 0) { + discardIgnoredHttpResponse(res); reject(new Error(`Redirect loop or missing Location header`)); return; } @@ -234,6 +239,7 @@ async function downloadToFile( try { redirectUrl = new URL(location, url); } catch { + discardIgnoredHttpResponse(res); reject(new Error("Invalid redirect Location header")); return; } @@ -241,12 +247,14 @@ async function downloadToFile( redirectUrl.origin === parsedUrl.origin ? headers : retainSafeHeadersForCrossOriginRedirect(headers); + discardIgnoredHttpResponse(res); resolve( downloadToFile(redirectUrl.href, dest, redirectHeaders, maxRedirects - 1, maxBytes), ); return; } if (!res.statusCode || res.statusCode >= 400) { + discardIgnoredHttpResponse(res); reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`)); return; }