Files
anonymous_github/src/server/routes/file.ts
T

156 lines
4.6 KiB
TypeScript

import * as express from "express";
import AnonymizedFile from "../../core/AnonymizedFile";
import AnonymousError from "../../core/AnonymousError";
import { getRepo, handleError } from "./route-utils";
import { fileETag } from "./file-etag";
export const router = express.Router();
function decodePathSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
try {
return decodeURI(segment);
} catch {
return segment;
}
}
}
// Extensions the browser will execute script from when it renders the
// response as a document (directly, or via the "Raw" action).
const SCRIPTABLE_EXTENSIONS = new Set([
"html",
"htm",
"xhtml",
"xht",
"svg",
"xml",
"xsl",
"xslt",
"mhtml",
]);
export function isScriptableDocument(anonymizedPath: string): boolean {
const name = anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1);
const dot = name.lastIndexOf(".");
if (dot < 0) return false;
return SCRIPTABLE_EXTENSIONS.has(name.substring(dot + 1).toLowerCase());
}
export function filePathFromRequestUrl(
reqUrl: string,
protocol: string,
hostname: string,
repoId: string
): string {
const pathname = new URL(reqUrl, `${protocol}://${hostname}`).pathname;
const prefix = `/${encodeURIComponent(repoId)}/file/`;
const rawPath = pathname.startsWith(prefix)
? pathname.substring(prefix.length)
: pathname.replace(`/${repoId}/file/`, "");
return rawPath.split("/").map(decodePathSegment).join("/");
}
router.get(
"/:repoId/file/:path*",
async (req: express.Request, res: express.Response) => {
const anonymizedPath = filePathFromRequestUrl(
req.url,
req.protocol,
req.hostname,
req.params.repoId
);
if (anonymizedPath.endsWith("/")) {
return handleError(
new AnonymousError("folder_not_supported", {
httpStatus: 404,
object: anonymizedPath,
}),
res
);
}
// Reject path traversal before the path reaches the storage layer. The
// storage backends also validate, but failing fast here keeps a crafted
// "../" URL from being treated as a real lookup (CWE-22/25).
if (
anonymizedPath
.split(/[\\/]/)
.some((segment) => segment === "..") ||
/^[\\/]/.test(anonymizedPath)
) {
return handleError(
new AnonymousError("invalid_path", {
httpStatus: 400,
object: anonymizedPath,
}),
res
);
}
const repo = await getRepo(req, res, {
nocheck: false,
});
if (!repo) return;
try {
if (!(await repo.isReady())) {
throw new AnonymousError("repository_not_ready", {
object: repo,
httpStatus: 425,
});
}
const f = new AnonymizedFile({
repository: repo,
anonymizedPath,
});
if (!f.isFileSupported()) {
throw new AnonymousError("file_not_supported", {
httpStatus: 403,
object: f,
});
}
if (req.query.download) {
res.attachment(
anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1)
);
} else if (isScriptableDocument(anonymizedPath)) {
// A repository's own .html/.svg is untrusted content served from our
// origin: opening it renders it as a document, and any script in it
// would run as the site itself (session cookie, same-origin fetches
// against /api). The CSP sandbox directive forces the response into an
// opaque origin, so the document still renders but can reach nothing
// of ours. Scripts are left out entirely, matching the file viewer's
// default (html-doc.js) — a reader who wants them opts in there.
// allow-same-origin must never be added: combined with allow-scripts
// it lets the document remove its own sandbox.
res.header(
"Content-Security-Policy",
"sandbox allow-popups allow-forms allow-modals"
);
}
const etag = fileETag(
req.query.v as string | undefined,
anonymizedPath,
repo.model.options
);
res.header("ETag", etag);
// Force the browser to revalidate every time. The previous 210-day
// max-age was keyed only on the upstream sha, so editing the
// anonymization term list left old anonymizations cached under the
// same URL.
res.header("Cache-Control", "private, no-cache, must-revalidate");
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
await f.send(res);
await repo.countView();
} catch (error) {
return handleError(error, res, req);
}
}
);
export default router;