From 641237de837d904dc6e999cc40b55d0ac6bac95a Mon Sep 17 00:00:00 2001 From: Henry Date: Tue, 10 Mar 2026 21:54:35 +0000 Subject: [PATCH] feat: add url validation to download attatchment endpoint --- .../web/src/pages/api/download/attatchment.ts | 98 ++++++++++++------- 1 file changed, 62 insertions(+), 36 deletions(-) diff --git a/apps/web/src/pages/api/download/attatchment.ts b/apps/web/src/pages/api/download/attatchment.ts index b697fb2d..4e538eca 100644 --- a/apps/web/src/pages/api/download/attatchment.ts +++ b/apps/web/src/pages/api/download/attatchment.ts @@ -2,48 +2,74 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { withRateLimit } from "@kan/api/utils/rateLimit"; +import { env } from "~/env"; + export default withRateLimit( { points: 100, duration: 60 }, async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== "GET") { - return res.status(405).json({ message: "Method not allowed" }); - } - - const { url, filename } = req.query; - - if (!url || typeof url !== "string") { - return res.status(400).json({ - message: "url parameter is required", - }); - } - - try { - const downloadFilename = typeof filename === "string" - ? encodeURIComponent(filename) - : "attachment"; - - const upstream = await fetch(url); - - if (!upstream.ok) { - return res.status(upstream.status).json({ - message: "Failed to fetch attachment", - }); + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); } - const contentType = - upstream.headers.get("Content-Type") ?? "application/octet-stream"; + const { url, filename } = req.query; - res.setHeader("Content-Type", contentType); - res.setHeader( - "Content-Disposition", - `attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`, - ); + if (!url || typeof url !== "string") { + return res.status(400).json({ message: "url parameter is required" }); + } - const buffer = await upstream.arrayBuffer(); - return res.send(Buffer.from(buffer)); - } catch (error) { - console.error("Error downloading attachment:", error); - return res.status(500).json({ message: "Failed to download attachment" }); - } + const s3Endpoint = env.S3_ENDPOINT; + + if (s3Endpoint) { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return res.status(400).json({ message: "Invalid URL" }); + } + + const hostname = parsed.hostname.toLowerCase(); + let allowedHost: string; + try { + allowedHost = new URL(s3Endpoint).hostname.toLowerCase(); + } catch { + return res.status(500).json({ message: "Storage endpoint misconfigured" }); + } + + if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) { + return res.status(403).json({ message: "URL not allowed" }); + } + } + + try { + const downloadFilename = + typeof filename === "string" + ? encodeURIComponent(filename) + : "attachment"; + + const upstream = await fetch(url); + + if (!upstream.ok) { + return res + .status(upstream.status) + .json({ message: "Failed to fetch attachment" }); + } + + const contentType = + upstream.headers.get("Content-Type") ?? "application/octet-stream"; + + res.setHeader("Content-Type", contentType); + res.setHeader( + "Content-Disposition", + `attachment; filename="${downloadFilename}"; filename*=UTF-8''${downloadFilename}`, + ); + + const buffer = await upstream.arrayBuffer(); + return res.send(Buffer.from(buffer)); + } catch (error) { + console.error("Error downloading attachment:", error); + return res + .status(500) + .json({ message: "Failed to download attachment" }); + } }, );