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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"core.min.js": "core.3db744fc07.min.js",
"vendor.min.js": "vendor.09f02f70c0.min.js",
"core.min.js": "core.6332b3c288.min.js",
"vendor.min.js": "vendor.d7d972f465.min.js",
"mermaid.min.js": "mermaid.f848a72d16.min.js",
"all.min.css": "all.1a9babcb45.min.css"
}
+2
View File
@@ -10,6 +10,7 @@
"user_not_found": "The requested user could not be found.",
"user_banned": "Your account has been banned. Contact the admin for more information.",
"repo_access_limited": "GitHub blocked access because the repository's organization restricts third-party OAuth apps. Ask an org owner to approve Anonymous GitHub under Settings → Third-party Access → OAuth app policy, or anonymize a personal fork instead.",
"repo_saml_enforcement": "The repository's organization enforces SAML single sign-on. Authorize your token for that organization (GitHub → Settings → Applications, or re-run the org's SSO sign-in), then retry. Alternatively, anonymize a personal fork.",
"repo_not_found": "The repository was not found on GitHub. Check the URL and spelling, make sure you are signed in to the account that can see it, and confirm the repo isn't hidden under an org that restricts third-party app access.",
"repo_empty": "The selected branch has no commits on GitHub. Push at least one commit, or pick a different branch, then retry.",
"repo_not_accessible": "Anonymous GitHub cannot access this repository. Verify the repository exists and that Anonymous GitHub has been authorized for the owning organization.",
@@ -52,6 +53,7 @@
"path_not_specified": "A file path must be specified.",
"path_not_defined": "The file path has not been resolved yet.",
"invalid_file_path": "The requested file path is not valid.",
"invalid_request": "The request is missing required fields or is malformed.",
"no_file_selected": "Please select a file.",
"file_not_found": "The requested file is not found.",
"file_not_accessible": "The requested file is not accessible.",
+7 -4
View File
@@ -88,7 +88,8 @@
ng-href="{{url}}"
target="__self"
class="btn btn-sm"
aria-label="Raw"
aria-label="View raw current file"
title="View the raw content of the current file"
><i class="fas fa-file-alt"></i><span class="d-none d-md-inline"> Raw</span></a
>
<a
@@ -96,7 +97,8 @@
ng-href="{{url}}&download=true"
target="__self"
class="btn btn-sm"
aria-label="Download"
aria-label="Download current file"
title="Download the current file"
><i class="fas fa-download"></i><span class="d-none d-md-inline"> Download</span></a
>
<a
@@ -104,8 +106,9 @@
ng-href="/api/repo/{{repoId}}/zip"
target="__self"
class="btn btn-sm"
aria-label="Download ZIP"
><i class="fas fa-file-archive"></i><span class="d-none d-md-inline"> ZIP</span></a
aria-label="Download full repository as ZIP"
title="Download the full repository as a ZIP archive"
><i class="fas fa-file-archive"></i><span class="d-none d-md-inline"> Full repo ZIP</span></a
>
<a
ng-if="options.hasWebsite"
+35 -5
View File
@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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) => {
+1 -1
View File
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -39,7 +39,12 @@ function markedMermaid(options) {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'loose'
// 'strict' keeps Mermaid's own HTML/script sanitisation and
// disables click-binding callbacks. 'loose' (the previous
// value) lets diagram syntax inject clickable elements with
// JavaScript handlers that run in the viewer's browser
// (XSS, CWE-79).
securityLevel: 'strict'
});
window.mermaidInitialized = true;
}
+11 -2
View File
@@ -54,12 +54,21 @@ function urlRel2abs(
) {
/* Only accept commonly trusted protocols:
* Only data-image URLs are accepted, Exotic flavours (escaped slash,
* html-entitied characters) are not supported to keep the function fast */
* html-entitied characters) are not supported to keep the function fast.
* "javascript:" is intentionally NOT allowed — returning such a URL
* unchanged would let it reach an href attribute and execute on click
* (XSS, CWE-79). */
if (
/^(https?|file|ftps?|mailto|javascript|data:image\/[^;]{2,9};):/i.test(url)
/^(https?|file|ftps?|mailto|data:image\/[^;]{2,9};):/i.test(url)
) {
return url; //Url is already absolute
}
// Block any other explicit scheme (javascript:, vbscript:, data:text/html,
// …) so it can't slip through as an "absolute" URL via the relative-path
// handling below.
if (/^\s*[a-z][a-z0-9+.-]*:/i.test(url)) {
return "";
}
if (url.substring(0, 2) == "//") return location.protocol + url;
else if (url.charAt(0) == "/") return baseUrl + url;
+1 -1
View File
File diff suppressed because one or more lines are too long