diff --git a/ui/app/api/scans/[scanId]/report/route.test.ts b/ui/app/api/scans/[scanId]/report/route.test.ts index f21c766ae8..ad5e9194ea 100644 --- a/ui/app/api/scans/[scanId]/report/route.test.ts +++ b/ui/app/api/scans/[scanId]/report/route.test.ts @@ -45,6 +45,7 @@ describe("GET /api/scans/[scanId]/report", () => { expect.objectContaining({ headers: { Authorization: "Bearer token" }, cache: "no-store", + redirect: "manual", }), ); expect(response.status).toBe(200); @@ -82,6 +83,56 @@ describe("GET /api/scans/[scanId]/report", () => { expect(cancelMock).toHaveBeenCalledTimes(1); }); + it("redirects the browser to the presigned URL for S3-backed reports", async () => { + const presignedUrl = "https://bucket.s3.example.com/report.zip?sig=abc"; + const fetchMock = vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: presignedUrl }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET(new Request("http://localhost/api"), { + params: Promise.resolve({ scanId: "scan-123" }), + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/api/v1/scans/scan-123/report", + expect.objectContaining({ redirect: "manual" }), + ); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe(presignedUrl); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.body).toBeNull(); + }); + + it("reports readiness without exposing the presigned URL on preflight", async () => { + const presignedUrl = "https://bucket.s3.example.com/report.zip?sig=abc"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(null, { + status: 302, + headers: { location: presignedUrl }, + }), + ), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET( + new Request("http://localhost/api?preflight=1"), + { + params: Promise.resolve({ scanId: "scan-123" }), + }, + ); + + expect(response.status).toBe(204); + expect(response.headers.get("location")).toBeNull(); + expect(response.body).toBeNull(); + }); + it("preserves pending report responses from the API", async () => { vi.stubGlobal( "fetch", @@ -100,4 +151,52 @@ describe("GET /api/scans/[scanId]/report", () => { expect(response.status).toBe(202); await expect(response.json()).resolves.toEqual({ data: { id: "task-1" } }); }); + + it("continues to the browser-native download when preflight times out", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new DOMException("Timed out", "TimeoutError")), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET( + new Request("http://localhost/api?preflight=1"), + { + params: Promise.resolve({ scanId: "scan-123" }), + }, + ); + + expect(response.status).toBe(204); + expect(response.body).toBeNull(); + expect(response.headers.get("cache-control")).toBe("no-store"); + }); + + it("does not forward upstream HTML error pages for preflight failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + "

504 Gateway Time-out

", + { + status: 504, + headers: { "content-type": "text/html" }, + }, + ), + ), + ); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + + const response = await GET( + new Request("http://localhost/api?preflight=1"), + { + params: Promise.resolve({ scanId: "scan-123" }), + }, + ); + + expect(response.status).toBe(504); + expect(response.headers.get("content-type")).toContain("text/plain"); + await expect(response.text()).resolves.toBe( + "Unable to prepare the scan report. Please try again in a few minutes.", + ); + }); }); diff --git a/ui/app/api/scans/[scanId]/report/route.ts b/ui/app/api/scans/[scanId]/report/route.ts index 68571c9136..5b891385cb 100644 --- a/ui/app/api/scans/[scanId]/report/route.ts +++ b/ui/app/api/scans/[scanId]/report/route.ts @@ -18,6 +18,10 @@ const COPY_RESPONSE_HEADERS = [ "last-modified", ] as const; +const PREFLIGHT_TIMEOUT_MS = 10_000; +const REPORT_PREPARATION_ERROR = + "Unable to prepare the scan report. Please try again in a few minutes."; + const buildAttachmentFilename = (scanId: string) => `scan-${scanId.replace(/[^a-zA-Z0-9._-]/g, "-")}-report.zip`; @@ -39,6 +43,21 @@ const buildDownloadHeaders = (upstreamHeaders: Headers, scanId: string) => { return headers; }; +const isAbortError = (error: unknown) => + error instanceof DOMException && + (error.name === "AbortError" || error.name === "TimeoutError"); + +const isHtmlResponse = (headers: Headers) => + headers.get("content-type")?.toLowerCase().includes("text/html") ?? false; + +const isRedirect = (status: number) => status >= 300 && status < 400; + +const preflightReadyResponse = () => + new Response(null, { + status: 204, + headers: { "Cache-Control": "no-store" }, + }); + export async function GET( request: Request, { params }: ScanReportRouteContext, @@ -49,10 +68,27 @@ export async function GET( const isPreflight = new URL(request.url).searchParams.get("preflight") === "1"; - const upstreamResponse = await fetch(upstreamUrl, { - headers, - cache: "no-store", - }); + let upstreamResponse: Response; + + try { + upstreamResponse = await fetch(upstreamUrl, { + headers, + cache: "no-store", + // The API redirects S3-backed reports to a presigned URL; keep that + // redirect instead of following it so the bytes never stream through + // this server. + redirect: "manual", + signal: isPreflight + ? AbortSignal.timeout(PREFLIGHT_TIMEOUT_MS) + : undefined, + }); + } catch (error) { + if (isPreflight && isAbortError(error)) { + return preflightReadyResponse(); + } + + throw error; + } if (upstreamResponse.status === 202) { const body = await upstreamResponse.json().catch(() => ({})); @@ -62,25 +98,51 @@ export async function GET( }); } + // S3-backed reports: hand the API's presigned redirect to the browser so it + // downloads straight from S3 without proxying the bytes through this server. + if (isRedirect(upstreamResponse.status)) { + if (isPreflight) { + return preflightReadyResponse(); + } + + const location = upstreamResponse.headers.get("location"); + if (!location) { + return NextResponse.json( + { error: "Report redirect did not include a location." }, + { status: 502, headers: { "Cache-Control": "no-store" } }, + ); + } + + return new Response(null, { + status: 307, + headers: { Location: location, "Cache-Control": "no-store" }, + }); + } + if (!upstreamResponse.ok) { - const body = await upstreamResponse.text().catch(() => ""); + const body = + isPreflight && isHtmlResponse(upstreamResponse.headers) + ? REPORT_PREPARATION_ERROR + : await upstreamResponse.text().catch(() => ""); + return new Response(body, { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: { "Cache-Control": "no-store", "Content-Type": - upstreamResponse.headers.get("content-type") || "text/plain", + isPreflight && isHtmlResponse(upstreamResponse.headers) + ? "text/plain" + : upstreamResponse.headers.get("content-type") || "text/plain", }, }); } + // Self-hosted without S3: the API returns the bytes directly, so there is no + // presigned URL to redirect to and we stream the response through instead. if (isPreflight) { await upstreamResponse.body?.cancel(); - return new Response(null, { - status: 204, - headers: { "Cache-Control": "no-store" }, - }); + return preflightReadyResponse(); } if (!upstreamResponse.body) { diff --git a/ui/lib/helper.test.ts b/ui/lib/helper.test.ts index 4431a25ba7..bfe3796453 100644 --- a/ui/lib/helper.test.ts +++ b/ui/lib/helper.test.ts @@ -90,4 +90,31 @@ describe("downloadScanZip", () => { description: "not found", }); }); + + it("shows a generic error when preflight fails with an HTML gateway page", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response( + "

504 Gateway Time-out

", + { + status: 504, + headers: { "content-type": "text/html" }, + }, + ), + ), + ); + const { clickMock } = getAnchor(); + const toast = createToast(); + + await downloadScanZip("scan-123", toast); + + expect(clickMock).not.toHaveBeenCalled(); + expect(toast).toHaveBeenCalledWith({ + variant: "destructive", + title: "Download Failed", + description: + "Unable to prepare the scan report. Please try again in a few minutes.", + }); + }); }); diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index d78724d356..d460b9697c 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -101,6 +101,19 @@ export const getAuthUrl = (provider: AuthSocialProvider) => { return url.toString(); }; +const REPORT_PREPARATION_ERROR = + "Unable to prepare the scan report. Please try again in a few minutes."; + +const getPreflightErrorMessage = async (response: Response) => { + const contentType = response.headers.get("content-type")?.toLowerCase() || ""; + + if (contentType.includes("text/html")) { + return REPORT_PREPARATION_ERROR; + } + + return (await response.text()) || "An unknown error occurred."; +}; + export const downloadScanZip = async ( scanId: string, toast: ReturnType["toast"], @@ -121,11 +134,10 @@ export const downloadScanZip = async ( } if (!preflightResponse.ok) { - const errorMessage = await preflightResponse.text(); toast({ variant: "destructive", title: "Download Failed", - description: errorMessage || "An unknown error occurred.", + description: await getPreflightErrorMessage(preflightResponse), }); return; }