mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-21 15:02:39 +02:00
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:
1 parent
bdfcc56d81
commit
e4ffd74068
21 files changed
+484
-23
No files matched your search
+16
-1
@@ -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 + "/";
|
||||
|
||||
|
||||
Reference in new issue
Block a user