From 9fae028c4b7a3ad4a49e9ce24f661606b734a442 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Tue, 14 Apr 2026 14:42:23 +0300 Subject: [PATCH 1/3] fix(security): normalise path before prefix check in project-proxy to prevent dot-segment traversal --- src/pages/api/project-proxy.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/api/project-proxy.ts b/src/pages/api/project-proxy.ts index 67cb31e..0964d4c 100644 --- a/src/pages/api/project-proxy.ts +++ b/src/pages/api/project-proxy.ts @@ -26,14 +26,18 @@ async function proxy(request: Request, locals: unknown): Promise { const url = new URL(request.url); const path = url.searchParams.get("path"); - if (!path || !path.startsWith("/api/method/")) { + // Normalise dot-segments (../, ./) before the prefix check so a crafted + // path like /api/method/../../api/auth/admin cannot bypass the guard. + const normalizedPath = path ? new URL(path, "http://localhost").pathname : null; + + if (!normalizedPath || !normalizedPath.startsWith("/api/method/")) { return new Response(JSON.stringify({ error: "Path not allowed" }), { status: 403, headers: { "Content-Type": "application/json" }, }); } - const upstream = `${apiUrl.replace(/\/$/, "")}${path}`; + const upstream = `${apiUrl.replace(/\/$/, "")}${normalizedPath}`; const headers = new Headers(request.headers); headers.set("Authorization", secretKey); From e2402595a96cca2c4c70314122d2126cdcd69680 Mon Sep 17 00:00:00 2001 From: mohanadft Date: Tue, 14 Apr 2026 14:45:08 +0300 Subject: [PATCH 2/3] fix(security): merge dead cache-control middleware into active CSP middleware via sequence() --- src/middleware/cache-control.ts | 11 +++++++++++ src/{middleware.ts => middleware/csp.ts} | 6 +++--- src/middleware/index.ts | 17 +++++++---------- 3 files changed, 21 insertions(+), 13 deletions(-) create mode 100644 src/middleware/cache-control.ts rename src/{middleware.ts => middleware/csp.ts} (93%) diff --git a/src/middleware/cache-control.ts b/src/middleware/cache-control.ts new file mode 100644 index 0000000..c8c1034 --- /dev/null +++ b/src/middleware/cache-control.ts @@ -0,0 +1,11 @@ +import { defineMiddleware } from "astro:middleware"; + +export const cacheControl = defineMiddleware(async (context, next) => { + const response = await next(); + + const isApi = context.url.pathname.startsWith("/api/"); + const isGet = context.request.method === "GET"; + response.headers.set("Cache-Control", !isGet || isApi ? "no-store" : "public, max-age=600"); + + return response; +}); diff --git a/src/middleware.ts b/src/middleware/csp.ts similarity index 93% rename from src/middleware.ts rename to src/middleware/csp.ts index e381576..7ee0a5a 100644 --- a/src/middleware.ts +++ b/src/middleware/csp.ts @@ -6,7 +6,7 @@ declare const HTMLRewriter: new () => { transform(response: Response): Response; }; -export const onRequest = defineMiddleware(async (context, next) => { +export const csp = defineMiddleware(async (context, next) => { const nonce = crypto.randomUUID().replace(/-/g, ""); context.locals.cspNonce = nonce; @@ -17,7 +17,7 @@ export const onRequest = defineMiddleware(async (context, next) => { return response; } - const csp = [ + const cspHeader = [ "default-src 'self'", // 'strict-dynamic' trusts scripts loaded by nonced scripts; removes need for 'unsafe-inline' `script-src 'nonce-${nonce}' 'strict-dynamic' https://secure.qgiv.com https://plausible.io https://pal-chat.net https://techforpalestine.org/cdn-cgi/`, @@ -52,6 +52,6 @@ export const onRequest = defineMiddleware(async (context, next) => { }); const transformed = rewriter.transform(response); - transformed.headers.set("Content-Security-Policy", csp); + transformed.headers.set("Content-Security-Policy", cspHeader); return transformed; }); diff --git a/src/middleware/index.ts b/src/middleware/index.ts index e6b4504..552c5cd 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -1,11 +1,8 @@ -import { defineMiddleware } from "astro:middleware"; +import { sequence } from "astro:middleware"; +import { cacheControl } from "./cache-control.js"; +import { csp } from "./csp.js"; -export const onRequest = defineMiddleware(async (context, next) => { - const response = await next(); - - const isApi = context.url.pathname.startsWith("/api/"); - const isGet = context.request.method === "GET"; - response.headers.set("Cache-Control", !isGet || isApi ? "no-store" : "public, max-age=600"); - - return response; -}); +// cacheControl runs first so the header is set on every response. +// csp runs second and may replace the response via HTMLRewriter; the +// cache-control header is preserved on the transformed response. +export const onRequest = sequence(cacheControl, csp); From 942da11e44ea0f786f388a83f4ebe0793910f7cc Mon Sep 17 00:00:00 2001 From: mohanadft Date: Tue, 14 Apr 2026 14:50:14 +0300 Subject: [PATCH 3/3] fix(security): replace header passthrough with explicit allowlist in project-proxy --- src/pages/api/project-proxy.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/pages/api/project-proxy.ts b/src/pages/api/project-proxy.ts index 0964d4c..73a487e 100644 --- a/src/pages/api/project-proxy.ts +++ b/src/pages/api/project-proxy.ts @@ -39,10 +39,15 @@ async function proxy(request: Request, locals: unknown): Promise { const upstream = `${apiUrl.replace(/\/$/, "")}${normalizedPath}`; - const headers = new Headers(request.headers); + // Explicit allowlist — never forward cookies, IP headers, or other + // browser-supplied headers that could influence upstream access controls. + const FORWARD_HEADERS = ["content-type", "accept", "accept-language", "accept-encoding"]; + const headers = new Headers(); + for (const name of FORWARD_HEADERS) { + const val = request.headers.get(name); + if (val) headers.set(name, val); + } headers.set("Authorization", secretKey); - // Don't forward host header to the upstream - headers.delete("host"); const upstreamResponse = await fetch(upstream, { method: request.method,