mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-06-30 02:55:30 +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,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"
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+6
-1
@@ -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
@@ -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;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user