Skip to content
Open
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
80 changes: 80 additions & 0 deletions app/api/auth/download-token/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
57 changes: 55 additions & 2 deletions components/FileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof useI18n>["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;
Expand Down Expand Up @@ -394,8 +431,24 @@ function TreeNode({
{hovered && !node.isDir && (
<a
href={`/api/files/${encodeFilePathForApi(node.fullPath)}?type=download`}
download
onClick={(e) => 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",
Expand Down
93 changes: 93 additions & 0 deletions lib/download-auth.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
106 changes: 106 additions & 0 deletions lib/download-auth.ts
Original file line number Diff line number Diff line change
@@ -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 `<expiry_epoch_ms>.<hmac-sha256 hex>` 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 `<exp>.<hex>` 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");
}
Loading