mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-06-29 18:50:00 +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:
+35
-5
@@ -88,13 +88,13 @@ angular
|
||||
controller: "claimController",
|
||||
title: "Claim an anonymization – Anonymous GitHub",
|
||||
})
|
||||
.when("/pr/:pullRequestId", {
|
||||
.when("/pr/:pullRequestId/:path*?", {
|
||||
templateUrl: "/partials/pullRequest.htm",
|
||||
controller: "pullRequestController",
|
||||
title: "Anonymous pull request – Anonymous GitHub",
|
||||
reloadOnUrl: false,
|
||||
})
|
||||
.when("/gist/:gistId", {
|
||||
.when("/gist/:gistId/:path*?", {
|
||||
templateUrl: "/partials/gist.htm",
|
||||
controller: "gistController",
|
||||
title: "Anonymous gist – Anonymous GitHub",
|
||||
@@ -593,6 +593,23 @@ angular
|
||||
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// Escape a value for safe interpolation into a single-quoted
|
||||
// AngularJS expression string (e.g. ng-click="openFolder('...')")
|
||||
// that itself sits inside a double-quoted HTML attribute which is
|
||||
// later $compile()d. Backslash/quote are escaped at the Angular
|
||||
// string level; &<>" are HTML-encoded for the attribute. Without
|
||||
// this a file name like `');$emit(...)//` would break out of the
|
||||
// expression string and execute (DOM XSS, CWE-79).
|
||||
function escapeNgString(str) {
|
||||
return String(str)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function buildSearchFilter() {
|
||||
const results = $scope.searchResults;
|
||||
if (!results || !results.length) return null;
|
||||
@@ -675,11 +692,12 @@ angular
|
||||
cssClasses.push("truncated");
|
||||
}
|
||||
|
||||
const ngPath = escapeNgString(path);
|
||||
output += `<li class="${cssClasses.join(
|
||||
" "
|
||||
)}" ng-class="{active: isActive('${path}'), open: ${filterSet ? "opens['" + path + "'] !== false" : "opens['" + path + "']"}}" title="${escapeHtml(sizeTitle)}">`;
|
||||
)}" ng-class="{active: isActive('${ngPath}'), open: ${filterSet ? "opens['" + ngPath + "'] !== false" : "opens['" + ngPath + "']"}}" title="${escapeHtml(sizeTitle)}">`;
|
||||
if (dir) {
|
||||
output += `<a ng-click="openFolder('${path}', $event)"><span class="tree-toggle"></span><span class="tree-icon-folder"></span><span class="tree-name">${escapeHtml(name)}</span>`;
|
||||
output += `<a ng-click="openFolder('${ngPath}', $event)"><span class="tree-toggle"></span><span class="tree-icon-folder"></span><span class="tree-name">${escapeHtml(name)}</span>`;
|
||||
if (truncated) {
|
||||
output += `<span class="truncated-warning" title="{{ 'WARNINGS.folder_truncated' | translate }}"><i class="fas fa-exclamation-triangle"></i></span>`;
|
||||
}
|
||||
@@ -911,7 +929,13 @@ angular
|
||||
const notebook = nb.parse(json);
|
||||
try {
|
||||
$element.html("");
|
||||
$element.append(notebook.render());
|
||||
// notebook.render() turns notebook JSON (markdown cells, cell
|
||||
// outputs) into HTML without sanitising it — a malicious
|
||||
// notebook could embed <script>/onerror handlers that execute
|
||||
// in the viewer's browser (XSS, CWE-79). Run the rendered
|
||||
// output through DOMPurify before inserting it.
|
||||
const rendered = notebook.render();
|
||||
$element.html(DOMPurify.sanitize(rendered));
|
||||
Prism.highlightAll();
|
||||
} catch (error) {
|
||||
$element.html("Unable to render the notebook.");
|
||||
@@ -3118,6 +3142,12 @@ angular
|
||||
$http.get(`/api/gist/${$scope.gistId}/content`).then(
|
||||
(res) => {
|
||||
$scope.details = res.data;
|
||||
// Pick the default tab once the content is loaded. The ng-init in
|
||||
// the template runs before this async response arrives (details is
|
||||
// still null then), so without this a files-only gist would default
|
||||
// to the hidden "comments" tab and render blank.
|
||||
const hasFiles = res.data && res.data.files && res.data.files.length;
|
||||
$scope.tabState = { active: hasFiles ? "files" : "comments" };
|
||||
if (callback) callback(res.data);
|
||||
},
|
||||
(err) => {
|
||||
|
||||
Reference in New Issue
Block a user