Security hardening + gist UI fixes (#731)

* security: harden against XSS, ReDoS, path traversal, and injection

Defensive fixes across the server, storage, and viewer:

- XSS (CWE-79): sanitise rendered notebooks with DOMPurify, escape file
  names interpolated into AngularJS expressions (escapeNgString), set
  Mermaid securityLevel to 'strict', and stop urlRel2abs from returning
  javascript:/vbscript:/data:text/html URLs.
- Path traversal / zip-slip (CWE-22/23/24): validate URL-derived path
  components before they reach the storage layer (file/webview routes +
  StorageBase.assertSafePath) and sanitise zip entry names on extract for
  both the filesystem and S3 backends.
- ReDoS (CWE-1333): escape anonymization terms with catastrophic
  backtracking shapes to literals instead of compiling them as regexes.
- Secret hardening (CWE-798): require SESSION_SECRET / OAuth creds / DB
  password in production, random dev SESSION_SECRET fallback.
- Rate-limit spoofing (CWE-290): derive request.ip via trust-proxy hop
  count instead of the client-settable cf-connecting-ip header.
- NoSQL injection (CWE-943): allow only plain field paths as admin sort keys.
- Reject malformed streamer requests missing required string fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ui): make gists reachable/visible and clarify the ZIP button

- Gist & PR routes now accept a trailing slash (/gist/:id/:path*?), so the
  dashboard links (which end in "/") resolve to the gist/PR page instead of
  falling through to the 404 route (#725).
- Gist viewer picks the default tab after content loads, defaulting to
  "files" when files exist; previously the ng-init ran before the async
  load and a files-only gist rendered blank under the hidden comments tab.
- Explorer toolbar: relabel ZIP to "Full repo ZIP" with a tooltip, and add
  tooltips to Raw/Download clarifying they apply to the current file (#721).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: report SAML-enforced orgs clearly instead of "token expired"

When a repo's organization enforces SAML SSO, GitHub returns a 403 whose
message differs from the OAuth-App-restriction case. That 403 fell through
to the generic handler and surfaced as "token_expired", pushing users to
re-login when the real fix is authorizing their token for the org. Detect
the "SAML enforcement" message and raise a dedicated, actionable error
instead (#379, #550).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* security: catch nested quantified groups in ReDoS guard and backslash path traversal

- hasCatastrophicBacktracking now scans across nested parens ([\s\S]*?)
  so shapes like ((a+))+ are detected; comment reframed as a heuristic
  backstop rather than a proof.
- file route path-traversal check now rejects backslash separators and a
  leading backslash, covering Windows-style "..\" payloads (CWE-22/25).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(dev): track dev-proxy script, ignore .DS_Store and .claude/

scripts/dev-proxy.js is referenced by the "dev:ui" npm script but was
never committed, breaking the command on a fresh clone. Add it and
ignore local-only macOS/Claude Code files.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Durieux
2026-06-18 04:50:55 -07:00
committed by GitHub
parent bdfcc56d81
commit e4ffd74068
21 changed files with 484 additions and 23 deletions
+30
View File
@@ -284,6 +284,29 @@ interface CompiledTermVariant {
mask: string;
}
// Detect exponential-backtracking regex shapes — a quantifier applied to a
// group that itself contains a quantifier or top-level alternation, e.g.
// (a+)+, (a*)*, (a|aa)+, and the nested form ((a+))+. Anonymization terms come
// from the repository owner and are applied as live regexes against file
// content, so a crafted term could otherwise hang the worker (ReDoS,
// CWE-1333/624). This is a heuristic, not a proof: the lazy [\s\S]*? body
// matches across nested parentheses so nested quantified groups are caught,
// and it errs toward over-escaping benign regexes rather than letting a
// dangerous one through. It is not exhaustive — exotic backtracking shapes may
// still slip past — so it backstops, rather than replaces, any execution-time
// bound on the regex.
function hasCatastrophicBacktracking(src: string): boolean {
const quantifiedGroup = /\(([\s\S]*?)\)\s*(?:[*+]|\{\d+(?:,\d*)?\})/g;
let match: RegExpExecArray | null;
while ((match = quantifiedGroup.exec(src)) !== null) {
const inner = match[1];
if (/[*+]|\{\d+(?:,\d*)?\}/.test(inner) || inner.includes("|")) {
return true;
}
}
return false;
}
function compileTerms(terms: string[] | undefined): CompiledTermVariant[] {
if (!terms || terms.length === 0) return [];
const compiled: CompiledTermVariant[] = [];
@@ -298,9 +321,16 @@ function compileTerms(terms: string[] | undefined): CompiledTermVariant[] {
parsed.replacement !== null
? parsed.replacement
: config.ANONYMIZATION_MASK + "-" + (i + 1);
// Use the term as a regex only when it both compiles AND is free of
// catastrophic-backtracking shapes; otherwise escape it to a literal so a
// malicious term cannot trigger ReDoS during anonymization.
let useAsRegex = true;
try {
new RegExp(term, "gi");
} catch {
useAsRegex = false;
}
if (!useAsRegex || hasCatastrophicBacktracking(term)) {
term = term.replace(/[-[\]{}()*+?.,\\^$|#]/g, "\\$&");
}
for (const variant of termVariants(term)) {
+18
View File
@@ -295,6 +295,24 @@ export async function getRepositoryFromGitHub(opt: {
cause: error as Error,
});
}
// SAML SSO-protected orgs return a 403 with a distinct message. Without
// this branch it falls through to the generic 401/403 handler below and
// is reported as "token_expired", which sends users to re-login instead
// of authorizing their token for the organization (the real fix). See
// #379/#550.
if (
error instanceof Error &&
error.message.includes("SAML enforcement")
) {
throw new AnonymousError("repo_saml_enforcement", {
httpStatus: 403,
object: {
owner: opt.owner,
repo: opt.repo,
},
cause: error as Error,
});
}
// If the name 404s but we know the GitHub repo id, the repo was
// probably renamed. Look it up by id and continue with the new name.
const status = (error as { status?: number }).status;
+15 -1
View File
@@ -24,6 +24,7 @@ export default class FileSystem extends StorageBase {
/** @override */
async exists(repoId: string, p: string = ""): Promise<FILE_TYPE> {
this.assertSafePath(p);
const fullPath = join(config.FOLDER, this.repoPath(repoId), p);
try {
const stat = await fs.promises.stat(fullPath);
@@ -37,17 +38,20 @@ export default class FileSystem extends StorageBase {
/** @override */
async send(repoId: string, p: string, res: Response) {
this.assertSafePath(p);
const fullPath = join(config.FOLDER, this.repoPath(repoId), p);
res.sendFile(fullPath, { dotfiles: "allow" });
}
/** @override */
async read(repoId: string, p: string): Promise<Readable> {
this.assertSafePath(p);
const fullPath = join(config.FOLDER, this.repoPath(repoId), p);
return fs.createReadStream(fullPath);
}
async fileInfo(repoId: string, path: string) {
this.assertSafePath(path);
const fullPath = join(config.FOLDER, this.repoPath(repoId), path);
const info = await fs.promises.stat(fullPath);
return {
@@ -67,6 +71,7 @@ export default class FileSystem extends StorageBase {
_source?: string,
expectedSize?: number
): Promise<void> {
this.assertSafePath(p);
const fullPath = join(config.FOLDER, this.repoPath(repoId), p);
// Atomic write: stream into a sibling .tmp and only rename into place
// when the source stream finishes successfully. If the source errors
@@ -126,6 +131,7 @@ export default class FileSystem extends StorageBase {
/** @override */
async rm(repoId: string, dir: string = ""): Promise<void> {
this.assertSafePath(dir);
const fullPath = join(config.FOLDER, this.repoPath(repoId), dir);
await fs.promises.rm(fullPath, {
force: true,
@@ -135,6 +141,7 @@ export default class FileSystem extends StorageBase {
/** @override */
async mk(repoId: string, dir: string = ""): Promise<void> {
this.assertSafePath(dir);
const fullPath = join(config.FOLDER, this.repoPath(repoId), dir);
try {
await fs.promises.mkdir(fullPath, {
@@ -155,6 +162,7 @@ export default class FileSystem extends StorageBase {
onEntry?: (file: { path: string; size: number }) => void;
} = {}
): Promise<IFile[]> {
this.assertSafePath(dir);
const fullPath = join(config.FOLDER, this.repoPath(repoId), dir);
const files = await fs.promises.readdir(fullPath);
const output2: IFile[] = [];
@@ -197,13 +205,18 @@ export default class FileSystem extends StorageBase {
/** @override */
async extractZip(repoId: string, p: string, data: Readable): Promise<void> {
this.assertSafePath(p);
const pipe = promisify(pipeline);
const fullPath = join(config.FOLDER, this.repoPath(repoId), p);
const extractor = Extract({
path: fullPath,
decodeString: (buf) => {
const name = buf.toString();
const newName = name.substr(name.indexOf("/") + 1);
// Strip the top-level directory GitHub wraps every entry in, then
// drop any "../" / absolute segments so a malicious entry name
// cannot escape the extraction root (zip-slip, CWE-24).
const stripped = name.substr(name.indexOf("/") + 1);
const newName = this.sanitizeZipEntryName(stripped);
if (newName == "") {
return "___IGNORE___";
}
@@ -223,6 +236,7 @@ export default class FileSystem extends StorageBase {
fileTransformer?: (path: string) => Transform;
}
) {
this.assertSafePath(dir);
const archive = archiver(opt?.format || "zip", {});
const fullPath = join(config.FOLDER, this.repoPath(repoId), dir);
+16 -1
View File
@@ -54,6 +54,7 @@ export default class S3Storage extends StorageBase {
/** @override */
async exists(repoId: string, path: string = ""): Promise<FILE_TYPE> {
if (!config.S3_BUCKET) throw new Error("S3_BUCKET not set");
this.assertSafePath(path);
try {
// if we can get the file info, it is a file
await this.fileInfo(repoId, path);
@@ -79,6 +80,7 @@ export default class S3Storage extends StorageBase {
/** @override */
async rm(repoId: string, dir: string = ""): Promise<void> {
if (!config.S3_BUCKET) throw new Error("S3_BUCKET not set");
this.assertSafePath(dir);
const data = await this.client(200000).listObjectsV2({
Bucket: config.S3_BUCKET,
Prefix: join(this.repoPath(repoId), dir),
@@ -110,6 +112,7 @@ export default class S3Storage extends StorageBase {
/** @override */
async send(repoId: string, path: string, res: Response) {
if (!config.S3_BUCKET) throw new Error("S3_BUCKET not set");
this.assertSafePath(path);
try {
const command = new GetObjectCommand({
Bucket: config.S3_BUCKET,
@@ -145,6 +148,7 @@ export default class S3Storage extends StorageBase {
async fileInfo(repoId: string, path: string) {
if (!config.S3_BUCKET) throw new Error("S3_BUCKET not set");
this.assertSafePath(path);
const info = await this.client(3000).headObject({
Bucket: config.S3_BUCKET,
Key: join(this.repoPath(repoId), path),
@@ -161,6 +165,7 @@ export default class S3Storage extends StorageBase {
/** @override */
async read(repoId: string, path: string): Promise<Readable> {
if (!config.S3_BUCKET) throw new Error("S3_BUCKET not set");
this.assertSafePath(path);
const command = new GetObjectCommand({
Bucket: config.S3_BUCKET,
Key: join(this.repoPath(repoId), path),
@@ -184,6 +189,7 @@ export default class S3Storage extends StorageBase {
expectedSize?: number
): Promise<void> {
if (!config.S3_BUCKET) throw new Error("S3_BUCKET not set");
this.assertSafePath(path);
// No fire-and-forget rm on stream error: Upload uses multipart and
// does not commit a partially-uploaded object, so there's nothing to
@@ -249,6 +255,7 @@ export default class S3Storage extends StorageBase {
/** @override */
async listFiles(repoId: string, dir: string = ""): Promise<IFile[]> {
if (!config.S3_BUCKET) throw new Error("S3_BUCKET not set");
this.assertSafePath(dir);
if (dir && dir[dir.length - 1] != "/") dir = dir + "/";
const out: IFile[] = [];
let req: ListObjectsV2CommandOutput;
@@ -287,6 +294,7 @@ export default class S3Storage extends StorageBase {
data: Readable,
source?: string
): Promise<void> {
this.assertSafePath(path);
let toS3: ArchiveStreamToS3;
return new Promise((resolve, reject) => {
if (!config.S3_BUCKET) return reject("S3_BUCKET not set");
@@ -296,7 +304,13 @@ export default class S3Storage extends StorageBase {
s3: this.client(2 * 60 * 60 * 1000), // 2h timeout
type: "zip",
onEntry: (header) => {
header.name = header.name.substring(header.name.indexOf("/") + 1);
// Strip the wrapping top-level dir, then drop any "../" / absolute
// segments so a crafted entry name cannot write objects outside
// the repo key prefix (zip-slip, CWE-23).
const stripped = header.name.substring(
header.name.indexOf("/") + 1
);
header.name = this.sanitizeZipEntryName(stripped);
if (source) {
header.Tagging = `source=${source}`;
header.Metadata = {
@@ -329,6 +343,7 @@ export default class S3Storage extends StorageBase {
}
) {
if (!config.S3_BUCKET) throw new Error("S3_BUCKET not set");
this.assertSafePath(dir);
const archive = archiver(opt?.format || "zip", {});
if (dir && dir[dir.length - 1] != "/") dir = dir + "/";
+53
View File
@@ -6,6 +6,7 @@ import { Response } from "express";
import S3Storage from "./S3";
import FileSystem from "./FileSystem";
import { IFile } from "../model/files/files.types";
import AnonymousError from "../AnonymousError";
export type Storage = S3Storage | FileSystem;
@@ -124,4 +125,56 @@ export default abstract class StorageBase {
join(repoId, "original") + (process.platform === "win32" ? "\\" : "/")
);
}
/**
* Reject any path/dir argument that could escape the per-repo base
* directory (filesystem) or key prefix (S3) once joined. The storage
* methods take a path component that ultimately derives from the request
* URL; `path.join`/key concatenation normalises `../` but does NOT stop it
* from climbing above the base. Validating the raw component before it is
* joined is the load-bearing defence against path traversal / zip-slip
* (CWE-22/23/24) for both backends.
*
* Throws AnonymousError(400) when the path is absolute or contains a `..`
* segment. An empty string (the repo root) is allowed.
*/
protected assertSafePath(p: string | undefined): void {
if (p == null || p === "") return;
if (typeof p !== "string") {
throw new AnonymousError("invalid_path", {
httpStatus: 400,
object: String(p),
});
}
// Absolute paths (POSIX "/x", Windows "C:\x" / "\x") must not be allowed
// to override the base in a join.
if (/^([a-zA-Z]:)?[\\/]/.test(p)) {
throw new AnonymousError("invalid_path", { httpStatus: 400, object: p });
}
for (const segment of p.split(/[\\/]/)) {
if (segment === "..") {
throw new AnonymousError("invalid_path", {
httpStatus: 400,
object: p,
});
}
}
}
/**
* Sanitise a single zip entry name during extraction. The archive
* extractors strip the leading top-level directory of each entry; this
* additionally drops any `..` / absolute components so a crafted entry like
* `repo/../../../etc/crontab` cannot escape the extraction root
* (zip-slip, CWE-23/24). Returns "" when nothing safe remains.
*/
protected sanitizeZipEntryName(name: string): string {
return name
.split(/[\\/]/)
.filter(
(segment) =>
segment !== "" && segment !== "." && segment !== ".."
)
.join("/");
}
}