Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 68 additions & 31 deletions web/src/app/api/_utils/backend-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

const { withBackendAuthMock } = vi.hoisted(() => ({
const { backendBaseUrlMock, withBackendAuthMock } = vi.hoisted(() => ({
backendBaseUrlMock: vi.fn(() => "https://backend.test"),
withBackendAuthMock: vi.fn(),
}))

vi.mock("./auth-headers", () => ({
backendBaseUrl: backendBaseUrlMock,
withBackendAuth: withBackendAuthMock,
}))

import { apiBaseUrl, proxyText } from "./backend-proxy"
import { apiBaseUrl, proxyJson, proxyText } from "./backend-proxy"

describe("apiBaseUrl", () => {
const originalPaperbotApiBaseUrl = process.env.PAPERBOT_API_BASE_URL

afterEach(() => {
if (originalPaperbotApiBaseUrl === undefined) {
delete process.env.PAPERBOT_API_BASE_URL
} else {
process.env.PAPERBOT_API_BASE_URL = originalPaperbotApiBaseUrl
}
})

it("uses PAPERBOT_API_BASE_URL when present", () => {
process.env.PAPERBOT_API_BASE_URL = "https://paperbot-api"
expect(apiBaseUrl()).toBe("https://paperbot-api")
})

it("falls back to localhost", () => {
delete process.env.PAPERBOT_API_BASE_URL
const fallback = new URL(apiBaseUrl())

expect(fallback.hostname).toBe("127.0.0.1")
expect(fallback.port).toBe("8000")
it("uses the shared backend base URL helper", () => {
expect(apiBaseUrl()).toBe("https://backend.test")
expect(backendBaseUrlMock).toHaveBeenCalledTimes(1)
})
})

describe("proxyText", () => {
describe("backend proxy helpers", () => {
const originalFetch = global.fetch

beforeEach(() => {
vi.resetAllMocks()
backendBaseUrlMock.mockReturnValue("https://backend.test")
})

afterEach(() => {
Expand All @@ -62,12 +47,13 @@
method: "GET",
headers: { Accept: "application/json" },
body: undefined,
signal: expect.any(AbortSignal),
})
expect(await res.text()).toBe(JSON.stringify({ ok: true }))
expect(res.status).toBe(200)
})

it("adds backend auth headers for protected writes", async () => {
it("proxies JSON writes with backend auth when requested", async () => {
withBackendAuthMock.mockResolvedValue({
Accept: "application/json",
"Content-Type": "application/json",
Expand All @@ -83,24 +69,75 @@
global.fetch = fetchMock as typeof fetch

const req = new Request("https://localhost/api/runbook/delete", {
method: "POST",
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "/workspace/demo" }),
body: JSON.stringify({ display_name: "PaperBot" }),
})
const res = await proxyText(req, "https://backend/api/runbook/delete", "POST", {
const res = await proxyJson(req, "https://backend/api/auth/me", "PATCH", {
auth: true,
})

expect(withBackendAuthMock).toHaveBeenCalledTimes(1)
expect(fetchMock).toHaveBeenCalledWith("https://backend/api/runbook/delete", {
method: "POST",
expect(fetchMock).toHaveBeenCalledWith("https://backend/api/auth/me", {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
authorization: "Bearer test-token",
},
body: JSON.stringify({ path: "/workspace/demo" }),
body: JSON.stringify({ display_name: "PaperBot" }),
signal: expect.any(AbortSignal),
})
expect(res.status).toBe(202)
})

it("returns an empty response when the upstream returns 204", async () => {
withBackendAuthMock.mockResolvedValue({
Accept: "application/json",
authorization: "Bearer test-token",
})

const fetchMock = vi.fn(async () => new Response(null, { status: 204 }))
global.fetch = fetchMock as typeof fetch

Check warning on line 101 in web/src/app/api/_utils/backend-proxy.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `global`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzwTo3CHwkPxaWmQWev&open=AZzwTo3CHwkPxaWmQWev&pullRequest=407

const req = new Request("https://localhost/api/auth/me", { method: "DELETE" })
const res = await proxyJson(req, "https://backend/api/auth/me", "DELETE", {
auth: true,
})

expect(fetchMock).toHaveBeenCalledWith("https://backend/api/auth/me", {
method: "DELETE",
headers: {
Accept: "application/json",
authorization: "Bearer test-token",
},
body: undefined,
signal: expect.any(AbortSignal),
})
expect(res.status).toBe(204)
expect(await res.text()).toBe("")
})

it("supports custom error handlers for upstream failures", async () => {
const fetchMock = vi.fn(async () => {
throw new Error("boom")
})
global.fetch = fetchMock as typeof fetch

Check warning on line 125 in web/src/app/api/_utils/backend-proxy.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `global`.

See more on https://sonarcloud.io/project/issues?id=jerry609_PaperBot&issues=AZzwTo3CHwkPxaWmQWew&open=AZzwTo3CHwkPxaWmQWew&pullRequest=407

const res = await proxyJson(
new Request("https://localhost/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "paperbot@example.com" }),
}),
"https://backend/api/auth/login",
"POST",
{
onError: () => Response.json({ detail: "Service unavailable" }, { status: 502 }),
},
)

expect(res.status).toBe(502)
await expect(res.json()).resolves.toEqual({ detail: "Service unavailable" })
})
})
182 changes: 159 additions & 23 deletions web/src/app/api/_utils/backend-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,181 @@
import { withBackendAuth } from "./auth-headers"
import { backendBaseUrl, withBackendAuth } from "./auth-headers"

type ProxyTextOptions = {
export type ProxyMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"

type ProxyErrorContext = {
error: unknown
isTimeout: boolean
upstreamUrl: string
}

type ProxyOptions = {
accept?: string
auth?: boolean
contentType?: string
onError?: (context: ProxyErrorContext) => Response
timeoutMs?: number
}

type TextProxyOptions = ProxyOptions & {
responseContentType?: string
responseHeaders?: HeadersInit
}

const DEFAULT_TIMEOUT_MS = 120_000

export function apiBaseUrl(): string {
return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000"
return backendBaseUrl()
}

export async function proxyJson(
req: Request,
upstreamUrl: string,
method: ProxyMethod,
options: TextProxyOptions = {},
): Promise<Response> {
return proxyText(req, upstreamUrl, method, {
accept: options.accept ?? "application/json",
responseContentType: options.responseContentType ?? "application/json",
...options,
})
}

export async function proxyText(
req: Request,
upstreamUrl: string,
method: string,
options: ProxyTextOptions = {},
method: ProxyMethod,
options: TextProxyOptions = {},
): Promise<Response> {
const requestOptions = {
accept: "application/json",
...options,
}

try {
const upstream = await fetchUpstream(req, upstreamUrl, method, requestOptions)
const text = await upstream.text()

return buildTextResponse(text, upstream, {
responseContentType: requestOptions.responseContentType,
responseHeaders: requestOptions.responseHeaders,
})
} catch (error) {
return handleProxyError(error, upstreamUrl, requestOptions.onError)
}
}

async function fetchUpstream(
req: Request,
upstreamUrl: string,
method: ProxyMethod,
options: ProxyOptions,
): Promise<Response> {
const normalizedMethod = method.toUpperCase()
const headers: Record<string, string> = {
Accept: options.accept || "application/json",
const controller = options.timeoutMs === 0 ? null : new AbortController()
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null
const body = await resolveBody(req, method)

try {
return await fetch(upstreamUrl, {
method,
headers: await resolveHeaders(req, body, options),
body,
signal: controller?.signal,
})
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
}

let body: string | undefined
if (normalizedMethod !== "GET" && normalizedMethod !== "HEAD") {
body = await req.text()
headers["Content-Type"] = req.headers.get("content-type") || "application/json"
async function resolveHeaders(
req: Request,
body: string | undefined,
options: ProxyOptions,
): Promise<HeadersInit> {
const headers: Record<string, string> = {}

if (options.accept) {
headers.Accept = options.accept
}

const upstreamHeaders = options.auth ? await withBackendAuth(req, headers) : headers
const upstream = await fetch(upstreamUrl, {
method: normalizedMethod,
headers: upstreamHeaders,
body,
})
const text = await upstream.text()
if (body !== undefined) {
headers["Content-Type"] =
options.contentType || req.headers.get("content-type") || "application/json"
}

if (options.auth) {
return withBackendAuth(req, headers)
}

return headers
}

async function resolveBody(
req: Request,
method: ProxyMethod,
): Promise<string | undefined> {
if (method === "GET" || method === "HEAD" || req.body === null) {
return undefined
}

return req.text()
}

function buildTextResponse(
text: string,
upstream: Response,
options: Pick<TextProxyOptions, "responseContentType" | "responseHeaders">,
): Response {
const headers = new Headers(options.responseHeaders)
headers.set("Cache-Control", "no-cache")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Hardcoding Cache-Control: no-cache might be too restrictive. It prevents caching of proxied responses, even if the upstream API sends its own caching headers. Consider respecting the upstream's Cache-Control header if it's present, and only falling back to no-cache as a default. This would make the proxy helper more flexible and allow leveraging backend caching strategies.

Suggested change
headers.set("Cache-Control", "no-cache")
if (upstream.headers.has("Cache-Control")) {
headers.set("Cache-Control", upstream.headers.get("Cache-Control")!)
} else {
headers.set("Cache-Control", "no-cache")
}


const contentType =
upstream.headers.get("content-type") || options.responseContentType

if (contentType && upstream.status !== 204 && text.length > 0) {
headers.set("Content-Type", contentType)
}

if (upstream.status === 204 || text.length === 0) {
return new Response(null, {
status: upstream.status,
headers,
})
}
Comment on lines +134 to +146

return new Response(text, {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("content-type") || options.responseContentType || "application/json",
"Cache-Control": "no-cache",
},
headers,
})
}

function handleProxyError(
error: unknown,
upstreamUrl: string,
onError?: (context: ProxyErrorContext) => Response,
): Response {
const isTimeout = error instanceof Error && error.name === "AbortError"
const context = {
error,
isTimeout,
upstreamUrl,
}

if (onError) {
return onError(context)
}

const detail = error instanceof Error ? error.message : String(error)

return Response.json(
{
detail: isTimeout
? `Upstream API timed out: ${upstreamUrl}`
: `Upstream API unreachable: ${upstreamUrl}`,
error: detail,
Comment on lines +170 to +177
},
{ status: 502 },
)
Comment on lines +170 to +180

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The default error handler exposes internal error details to the client via the error field in the JSON response. This is a potential information disclosure vulnerability, as it could leak sensitive information about the infrastructure. It's safer to return a generic error message to the client and log the specific error on the server for debugging purposes.

Suggested change
const detail = error instanceof Error ? error.message : String(error)
return Response.json(
{
detail: isTimeout
? `Upstream API timed out: ${upstreamUrl}`
: `Upstream API unreachable: ${upstreamUrl}`,
error: detail,
},
{ status: 502 },
)
// The original error should be logged on the server for debugging.
// console.error(`[backend-proxy] Upstream fetch to ${upstreamUrl} failed:`, error);
return Response.json(
{
detail: isTimeout
? `Upstream API timed out: ${upstreamUrl}`
: `Upstream API unreachable: ${upstreamUrl}`,
},
{ status: 502 },
)

}
14 changes: 3 additions & 11 deletions web/src/app/api/auth/forgot-password/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import { NextRequest, NextResponse } from "next/server"
import { backendBaseUrl } from "@/app/api/_utils/auth-headers"
import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy"

export async function POST(req: NextRequest) {
const body = await req.json()
const res = await fetch(`${backendBaseUrl()}/api/auth/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => null)
return NextResponse.json(data, { status: res.status })
export async function POST(req: Request) {
return proxyJson(req, `${apiBaseUrl()}/api/auth/forgot-password`, "POST")
}
16 changes: 3 additions & 13 deletions web/src/app/api/auth/login-check/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,9 @@
export const runtime = "nodejs"

import { backendBaseUrl } from "../../_utils/auth-headers"
import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy"

export async function POST(req: Request) {
const body = await req.text()
const res = await fetch(`${backendBaseUrl()}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
}).catch(() => null)

if (!res) return Response.json({ detail: "Service unavailable" }, { status: 502 })
const text = await res.text()
return new Response(text, {
status: res.status,
headers: { "Content-Type": "application/json" },
return proxyJson(req, `${apiBaseUrl()}/api/auth/login`, "POST", {
onError: () => Response.json({ detail: "Service unavailable" }, { status: 502 }),
})
}
Loading
Loading