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;
}