diff --git a/app/api/auth/download-token/route.ts b/app/api/auth/download-token/route.ts new file mode 100644 index 000000000..ecc18c6ce --- /dev/null +++ b/app/api/auth/download-token/route.ts @@ -0,0 +1,80 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "node:fs"; +import { + getAllowedFileRoots, + isExistingFilePathAllowed, + isFilePathAllowed, + normalizeSlashes, +} from "@/lib/file-access"; +import { encodeFilePathForApi } from "@/lib/file-paths"; +import { + DOWNLOAD_TOKEN_TTL_MS, + createDownloadToken, + getDownloadSecret, +} from "@/lib/download-auth"; + +export const dynamic = "force-dynamic"; + +/** + * Issue a one-time signed download token for a file. + * + * The endpoint is protected by Basic Auth (enforced in proxy.ts). Wrapper apps + * (e.g. Pake) replay download requests through their own HTTP client without + * the page's Basic credentials, so they rely on the short-lived signed token + * issued here to pass the proxy check. + * + * Query parameters: + * - path: absolute path of the target file (URL-encoded) + * + * Returns `{ token, expiresAt }` on success, or a 4xx JSON error. + * + * @param request - GET request with the `path` query parameter + * @returns JSON response carrying the download token + */ +export async function GET(request: NextRequest) { + const rawPath = request.nextUrl.searchParams.get("path"); + if (!rawPath) { + return NextResponse.json( + { error: "Missing path parameter" }, + { status: 400 }, + ); + } + + let filePath: string; + try { + filePath = decodeURIComponent(rawPath); + } catch { + return NextResponse.json( + { error: "Invalid path parameter" }, + { status: 400 }, + ); + } + filePath = normalizeSlashes(filePath); + + const allowedRoots = await getAllowedFileRoots(); + if (!isFilePathAllowed(filePath, allowedRoots)) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + let stat: fs.Stats; + try { + stat = fs.statSync(filePath); + } catch { + return NextResponse.json({ error: "File not found" }, { status: 404 }); + } + if (!stat.isFile()) { + return NextResponse.json({ error: "Not a file" }, { status: 400 }); + } + + // Re-check after resolving symlinks so a link cannot escape the allowed roots. + if (!isExistingFilePathAllowed(filePath, allowedRoots)) { + return NextResponse.json({ error: "Access denied" }, { status: 403 }); + } + + const pathname = `/api/files/${encodeFilePathForApi(filePath)}`; + const token = createDownloadToken(getDownloadSecret(), pathname); + return NextResponse.json({ + token, + expiresAt: Date.now() + DOWNLOAD_TOKEN_TTL_MS, + }); +} diff --git a/components/FileExplorer.tsx b/components/FileExplorer.tsx index 6803f21c5..f4e60c1e4 100644 --- a/components/FileExplorer.tsx +++ b/components/FileExplorer.tsx @@ -12,8 +12,45 @@ import { } from "@/lib/file-paths"; import type { GitFileStatus, GitFileStatusKind, GitStatusResponse } from "@/lib/git-types"; import { useI18n } from "@/hooks/useI18n"; +import { isPakeInterceptedDownload } from "@/lib/pake-download-extensions"; type Translate = ReturnType["t"]; +/** + * Build a download URL with a short-lived signed download token. + * + * Wrapper apps (e.g. Pake) replay download links through their own HTTP client + * without the page's Basic credentials, and they intercept clicks in the + * capture phase before React handlers run. The token therefore must be written + * into the href before the click happens (prefetched on hover; see the + * download button's onMouseEnter). Falls back to the plain link when token + * issuance fails (browser clients still carry credentials and download fine). + */ +async function buildDownloadUrl( + fullPath: string, +): Promise<{ url: string; expiresAt: number }> { + const url = `/api/files/${encodeFilePathForApi(fullPath)}?type=download`; + try { + const response = await fetch( + `/api/auth/download-token?path=${encodeURIComponent(fullPath)}`, + ); + if (response.ok) { + const { token, expiresAt } = (await response.json()) as { + token?: string; + expiresAt?: number; + }; + if (token) { + return { + url: `${url}&dt=${encodeURIComponent(token)}`, + expiresAt: Number(expiresAt) || 0, + }; + } + } + } catch { + // Fall back to the plain link when issuance fails. + } + return { url, expiresAt: 0 }; +} + interface FileEntry { name: string; isDir: boolean; @@ -394,8 +431,24 @@ function TreeNode({ {hovered && !node.isDir && ( e.stopPropagation()} + download={ + isPakeInterceptedDownload(node.fullPath) + ? getFileName(node.fullPath) + : undefined + } + onMouseEnter={(e) => { + // Pake intercepts clicks in the capture phase, so the token must + // be in the href before the click. Re-issue only when the armed + // token has expired to avoid re-fetching on every hover. + const current = e.currentTarget as HTMLAnchorElement; + const armedUntil = Number(current.dataset.dtExpires ?? 0); + if (armedUntil > Date.now()) return; + void (async () => { + const { url, expiresAt } = await buildDownloadUrl(node.fullPath); + current.setAttribute("href", url); + if (expiresAt > 0) current.dataset.dtExpires = String(expiresAt); + })(); + }} title={t("files.download")} style={{ position: "absolute", diff --git a/lib/download-auth.test.mjs b/lib/download-auth.test.mjs new file mode 100644 index 000000000..d242c57f4 --- /dev/null +++ b/lib/download-auth.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); + +const { DOWNLOAD_TOKEN_TTL_MS, createDownloadToken, verifyDownloadToken } = + await jiti.import("./download-auth.ts"); + +const SECRET = "test-secret-12345"; + +test("token verifies after issuance", () => { + const token = createDownloadToken( + SECRET, + "/api/files/home/user/%E6%8A%A5%E8%A1%A8.xlsx", + ); + assert.equal( + verifyDownloadToken( + SECRET, + "/api/files/home/user/%E6%8A%A5%E8%A1%A8.xlsx", + token, + ), + true, + ); +}); + +test("token fails when the pathname is tampered with", () => { + const token = createDownloadToken(SECRET, "/api/files/home/user/a.docx"); + assert.equal( + verifyDownloadToken(SECRET, "/api/files/home/user/b.docx", token), + false, + ); +}); + +test("expired token fails verification", () => { + const now = Date.now(); + const token = createDownloadToken(SECRET, "/api/files/home/user/a.docx", now); + assert.equal( + verifyDownloadToken( + SECRET, + "/api/files/home/user/a.docx", + token, + now + DOWNLOAD_TOKEN_TTL_MS + 1, + ), + false, + ); +}); + +test("token verifies anywhere inside the TTL window", () => { + const now = Date.now(); + const token = createDownloadToken(SECRET, "/api/files/home/user/a.docx", now); + assert.equal( + verifyDownloadToken( + SECRET, + "/api/files/home/user/a.docx", + token, + now + DOWNLOAD_TOKEN_TTL_MS - 1, + ), + true, + ); +}); + +test("token fails with a different secret", () => { + const token = createDownloadToken(SECRET, "/api/files/home/user/a.docx"); + assert.equal( + verifyDownloadToken("other-secret", "/api/files/home/user/a.docx", token), + false, + ); +}); + +test("malformed tokens are rejected", () => { + assert.equal( + verifyDownloadToken(SECRET, "/api/files/home/user/a.docx", "garbage"), + false, + ); + assert.equal( + verifyDownloadToken(SECRET, "/api/files/home/user/a.docx", "123.zzz"), + false, + ); + assert.equal( + verifyDownloadToken(SECRET, "/api/files/home/user/a.docx", ""), + false, + ); +}); + +test("empty pathname or empty token is rejected", () => { + const token = createDownloadToken(SECRET, "/api/files/home/user/a.docx"); + assert.equal(verifyDownloadToken(SECRET, "", token), false); + assert.equal( + verifyDownloadToken(SECRET, "/api/files/home/user/a.docx", null), + false, + ); +}); diff --git a/lib/download-auth.ts b/lib/download-auth.ts new file mode 100644 index 000000000..9c2abe381 --- /dev/null +++ b/lib/download-auth.ts @@ -0,0 +1,106 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** Download token TTL in milliseconds. Covers the hover-to-click gap. */ +export const DOWNLOAD_TOKEN_TTL_MS = 5 * 60_000; + +/** Purpose string bound into every signature so a token cannot be reused elsewhere. */ +const DOWNLOAD_PURPOSE = "download"; + +declare global { + var __piDownloadSecret: string | undefined; +} + +/** + * Resolve the signing secret for download tokens. + * + * Prefers the PI_WEB_DOWNLOAD_SECRET environment variable (an explicit + * operator choice for cross-restart/process stability). Otherwise a random + * 32-byte key is generated once and cached on globalThis — issuance and + * verification always happen in the same process, so a random secret is + * sufficient, and keeping it in memory (never persisted) shrinks exposure. + * + * @returns the signing secret + */ +export function getDownloadSecret(): string { + const fromEnv = process.env.PI_WEB_DOWNLOAD_SECRET; + if (fromEnv) return fromEnv; + if (!globalThis.__piDownloadSecret) { + globalThis.__piDownloadSecret = randomBytes(32).toString("hex"); + } + return globalThis.__piDownloadSecret; +} + +/** + * Create a one-time download token for a download target. + * + * The token is `.` where the signature input + * is `pathname|exp|download`. It therefore cannot be replayed against another + * path or purpose, and tampering with the expiry invalidates the signature. + * + * @param secret - the signing secret (from getDownloadSecret) + * @param pathname - API path of the download target, e.g. `/api/files/home/user/report.xlsx` (URL-encoded form) + * @param now - current time in ms (injectable for tests) + * @returns the `.` download token + */ +export function createDownloadToken( + secret: string, + pathname: string, + now: number = Date.now(), +): string { + const exp = now + DOWNLOAD_TOKEN_TTL_MS; + const signature = sign(secret, pathname, exp); + return `${exp}.${signature}`; +} + +/** + * Verify a download token. + * + * @param secret - the signing secret (same one used at issuance) + * @param pathname - the API path of the current request; must exactly match the one bound at issuance + * @param token - the token carried by the request + * @param now - current time in ms (injectable for tests) + * @returns true when the token is valid; false for malformed, expired, or tampered tokens + */ +export function verifyDownloadToken( + secret: string, + pathname: string, + token: string | null | undefined, + now: number = Date.now(), +): boolean { + if (typeof token !== "string" || !token) return false; + if (!pathname) return false; + + const dotIndex = token.lastIndexOf("."); + if (dotIndex <= 0) return false; + + const expText = token.slice(0, dotIndex); + const signature = token.slice(dotIndex + 1); + if (!/^\d+$/.test(expText)) return false; + if (!/^[0-9a-f]{64}$/i.test(signature)) return false; + + const exp = Number(expText); + if (!Number.isSafeInteger(exp)) return false; + // Expired tokens are rejected (a longer expiry cannot be forged: tampering + // with exp breaks the signature). + if (now > exp) return false; + + const expected = sign(secret, pathname, exp); + return timingSafeEqual( + Buffer.from(signature, "hex"), + Buffer.from(expected, "hex"), + ); +} + +/** + * Compute the token signature. + * + * @param secret - the signing secret + * @param pathname - the API path of the download target + * @param exp - expiry time in ms + * @returns HMAC-SHA256 hex digest + */ +function sign(secret: string, pathname: string, exp: number): string { + return createHmac("sha256", secret) + .update(`${pathname}|${exp}|${DOWNLOAD_PURPOSE}`) + .digest("hex"); +} diff --git a/lib/pake-download-extensions.test.mjs b/lib/pake-download-extensions.test.mjs new file mode 100644 index 000000000..7373c49b0 --- /dev/null +++ b/lib/pake-download-extensions.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); +const { isPakeInterceptedDownload } = await jiti.import( + "./pake-download-extensions.ts", +); + +test("extensions in the Pake hijack set are intercepted", () => { + for (const name of [ + "report.docx", + "sheet.xlsx", + "doc.pdf", + "archive.zip", + "data.csv", + "run.sh", + "font.ttf", + "model.psd", + "image.raw", + "setup.exe", + ]) { + assert.equal( + isPakeInterceptedDownload(`/home/user/project/${name}`), + true, + name, + ); + } +}); + +test("extensions outside the Pake hijack set are not intercepted", () => { + for (const name of [ + "README.md", + "package.json", + "index.html", + "script.js", + "style.css", + "config.yaml", + "notes.txt.multipart", // multi-dot names take the final segment, not in the set + ]) { + assert.equal( + isPakeInterceptedDownload(`/home/user/project/${name}`), + false, + name, + ); + } +}); + +test("previewable media types are not intercepted", () => { + for (const name of [ + "photo.png", + "pic.jpg", + "clip.mp4", + "audio.mp3", + "vector.svg", + ]) { + assert.equal( + isPakeInterceptedDownload(`/home/user/project/${name}`), + false, + name, + ); + } +}); + +test("files without an extension are not intercepted", () => { + assert.equal(isPakeInterceptedDownload("/home/user/project/Makefile"), false); + assert.equal(isPakeInterceptedDownload(""), false); +}); diff --git a/lib/pake-download-extensions.ts b/lib/pake-download-extensions.ts new file mode 100644 index 000000000..a046134fa --- /dev/null +++ b/lib/pake-download-extensions.ts @@ -0,0 +1,132 @@ +/** + * Downloadable-extension set aligned with the DOWNLOADABLE_FILE_EXTENSIONS + * list in Pake's injected script (https://github.com/tw93/Pake). Pake hijacks + * clicks on `` elements whose extension is in this set (and not a + * previewable media type), replaying them through its own HTTP client; files + * outside the set go through the native webview download path. + * + * The FileExplorer uses this to decide whether to attach a readable `download` + * attribute (Pake prefers its value as the saved filename, which fixes + * percent-encoded Chinese filenames) — only files Pake will hijack need it. + * + * This module is used by client components, so it must not depend on node:path. + * Note: keep in sync when Pake updates its list. + */ +export const PAKE_DOWNLOADABLE_EXTENSIONS: ReadonlySet = new Set([ + // documents + "pdf", + "doc", + "docx", + "xls", + "xlsx", + "ppt", + "pptx", + "txt", + "rtf", + "odt", + "ods", + "odp", + "pages", + "numbers", + "key", + "epub", + "mobi", + // archives + "zip", + "rar", + "7z", + "tar", + "gz", + "gzip", + "bz2", + "xz", + "lzma", + "deb", + "rpm", + "pkg", + "msi", + "exe", + "dmg", + "apk", + "ipa", + // data + "csv", + "sql", + "db", + "sqlite", + // scripts + "sh", + "bat", + "ps1", + // fonts + "ttf", + "otf", + "woff", + "woff2", + "eot", + // design + "ai", + "psd", + "sketch", + "fig", + "xd", + // system + "iso", + "img", + "bin", + "torrent", + "jar", + "war", + "indd", + "fla", + "swf", + "raw", +]); + +/** + * Previewable media types Pake treats as native (images/audio/video) — these + * are never hijacked even if the extension looks downloadable. + */ +export const PAKE_PREVIEWABLE_MEDIA_EXTENSIONS: ReadonlySet = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "webp", + "svg", + "bmp", + "tiff", + "tif", + "avif", + "heic", + "heif", + "mp4", + "webm", + "mov", + "m4v", + "mkv", + "avi", + "ogv", + "mp3", + "wav", + "ogg", + "flac", + "aac", + "m4a", +]); + +/** + * Whether a file will be hijacked by Pake as a download (extension in the + * downloadable set and not a previewable media type). + * + * @param filePath - absolute file path or file name + * @returns true when Pake will hijack the download + */ +export function isPakeInterceptedDownload(filePath: string): boolean { + const base = (filePath.split("/").pop() ?? "").toLowerCase(); + const dots = base.split("."); + const ext = dots.length > 1 ? (dots.pop() ?? "") : ""; + if (!ext) return false; + if (PAKE_PREVIEWABLE_MEDIA_EXTENSIONS.has(ext)) return false; + return PAKE_DOWNLOADABLE_EXTENSIONS.has(ext); +} diff --git a/proxy.ts b/proxy.ts index 31bacfa7a..b248c95b1 100644 --- a/proxy.ts +++ b/proxy.ts @@ -7,10 +7,48 @@ import { isValidBasicAuthorization, isWebPasswordEnabled, } from "@/lib/web-auth"; +import { getDownloadSecret, verifyDownloadToken } from "@/lib/download-auth"; + +/** + * A download request carrying a valid signed token may skip Basic auth. + * + * Wrapper apps (e.g. Pake) replay download URLs through their own HTTP client + * and do not carry the page's cached Basic credentials; rejecting those + * requests would break every file download inside the wrapper. Instead the URL + * carries a short-lived signed token (bound to the path, ~5 min TTL) issued by + * the download-token endpoint. Valid tokens pass through to the file route; + * missing/invalid tokens still fall through to the regular Basic check. + */ +function isSignedDownloadRequest(request: NextRequest): boolean { + const { pathname, searchParams } = request.nextUrl; + if (!pathname.startsWith("/api/files/")) return false; + if (searchParams.get("type") !== "download") return false; + + const token = searchParams.get("dt"); + if (!token) return false; + return verifyDownloadToken(getDownloadSecret(), pathname, token); +} + +/** + * True when the request is a download-shaped request that carries a `dt` token + * which fails verification (distinguished from plain unauthenticated 401s). + * + * @param request - the current request + * @returns true when the request looks like a download and carries an invalid token + */ +function isInvalidDownloadTokenRequest(request: NextRequest): boolean { + const { pathname, searchParams } = request.nextUrl; + if (!pathname.startsWith("/api/files/")) return false; + if (searchParams.get("type") !== "download") return false; + const token = searchParams.get("dt"); + if (!token) return false; + return !verifyDownloadToken(getDownloadSecret(), pathname, token); +} export function proxy(request: NextRequest) { - const isApiRequest = request.nextUrl.pathname === "/api" - || request.nextUrl.pathname.startsWith("/api/"); + const isApiRequest = + request.nextUrl.pathname === "/api" || + request.nextUrl.pathname.startsWith("/api/"); const isTrustedRequest = isApiRequest ? isApiRequestAllowed(request) : isApiRequestHostAllowed(request); @@ -19,13 +57,31 @@ export function proxy(request: NextRequest) { if (!isApiRequest) { return new NextResponse("Untrusted request", { status: 403 }); } - return NextResponse.json({ error: "Untrusted API request" }, { status: 403 }); + return NextResponse.json( + { error: "Untrusted API request" }, + { status: 403 }, + ); + } + + // Download requests with an invalid token get an explicit 403 (vs. 401 for + // unauthenticated requests). + if (isInvalidDownloadTokenRequest(request)) { + return NextResponse.json( + { error: "Invalid download token" }, + { status: 403 }, + ); + } + + // Download requests with a valid signed token pass through without Basic + // (checked after the host/origin checks). + if (isSignedDownloadRequest(request)) { + return NextResponse.next(); } const password = process.env.PI_WEB_PASSWORD; if ( - isWebPasswordEnabled(password) - && !isValidBasicAuthorization(request.headers.get("authorization"), password) + isWebPasswordEnabled(password) && + !isValidBasicAuthorization(request.headers.get("authorization"), password) ) { return new NextResponse("Authentication required", { status: 401,