mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
fix: harden repository content and improve access recovery
Sandbox executable MIME aliases, enforce strict Mermaid rendering, and add security regression coverage. Upgrade vulnerable dependencies with archive compatibility checks and Node 22/24 CI. Make repository restoration atomic and provide actionable viewer errors with retry controls.
This commit is contained in:
1 parent
772e40debd
commit
a301c46a94
21 files changed
+1048
-1051
No files matched your search
@@ -0,0 +1,43 @@
|
||||
const { expect } = require("chai");
|
||||
const fs = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { once } = require("node:events");
|
||||
const archiver = require("archiver");
|
||||
const { Parse } = require("unzip-stream");
|
||||
|
||||
describe("archive glob dependency compatibility", function () {
|
||||
it("archives nested matches and excludes ignored files with the patched matcher", async function () {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "archive-dependencies-"));
|
||||
const archive = archiver("zip");
|
||||
const parser = Parse();
|
||||
try {
|
||||
await fs.mkdir(path.join(directory, "nested"));
|
||||
await Promise.all(["README.md", "nested/notes.md", "nested/private.md", "nested/code.js"]
|
||||
.map(name => fs.writeFile(path.join(directory, name), name)));
|
||||
// archiver-utils uses glob.sync, while archiver.glob uses readdir-glob
|
||||
// and Minimatch. Exercise both callers of the major-version overrides.
|
||||
expect(require("archiver-utils").file.expand({ cwd: directory }, ["**/*.md", "!**/private.md"]).sort())
|
||||
.to.deep.equal(["README.md", "nested/notes.md"]);
|
||||
const entries = [];
|
||||
const reads = [];
|
||||
parser.on("entry", entry => {
|
||||
const chunks = [];
|
||||
entry.on("data", chunk => chunks.push(chunk));
|
||||
reads.push(once(entry, "end").then(() => entries.push({ name: entry.path, body: Buffer.concat(chunks).toString() })));
|
||||
});
|
||||
const finished = once(parser, "finish");
|
||||
archive.pipe(parser);
|
||||
archive.glob("**/*.md", { cwd: directory, ignore: ["**/private.md"] });
|
||||
await archive.finalize();
|
||||
await finished;
|
||||
await Promise.all(reads);
|
||||
expect(entries.sort((a, b) => a.name.localeCompare(b.name)))
|
||||
.to.deep.equal(["nested/notes.md", "README.md"].map(name => ({ name, body: name })));
|
||||
} finally {
|
||||
archive.destroy();
|
||||
parser.destroy();
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,35 @@ describe("download headers and website revalidation", function () {
|
||||
expect(response.headers["etag"]).to.match(/^"f-/); expect(response.headers["content-type"]).to.include("text/plain");
|
||||
expect(transfers).to.equal(0);
|
||||
});
|
||||
for (const name of ["page.shtml", "drawing.svgz", "schema.xsd"]) {
|
||||
it(`sandboxes ${name} on GET, HEAD, and revalidation`, async function () {
|
||||
const path = `/api/repo/fixture/file/${name}`;
|
||||
const first = await fetch(path);
|
||||
const head = await fetch(path, "HEAD");
|
||||
const cached = await fetch(path, "GET", { "If-None-Match": first.headers.etag });
|
||||
expect(first.status).to.equal(200);
|
||||
expect(head.status).to.equal(200);
|
||||
expect(cached.status).to.equal(304);
|
||||
for (const response of [first, head, cached]) {
|
||||
expect(response.headers["content-security-policy"]).to.include("sandbox");
|
||||
expect(response.headers["content-security-policy"]).not.to.include("allow-scripts");
|
||||
expect(response.headers["content-security-policy"]).not.to.include("allow-same-origin");
|
||||
expect(response.headers["x-content-type-options"]).to.equal("nosniff");
|
||||
}
|
||||
});
|
||||
}
|
||||
it("sandboxes MIME aliases even when anonymization changes their extension", async function () {
|
||||
AnonymizedFile.prototype.originalPath = async () => "original.shtml";
|
||||
const response = await fetch("/api/repo/fixture/file/page.txt");
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.headers["content-security-policy"]).to.include("sandbox");
|
||||
});
|
||||
it("keeps explicit downloads as attachments", async function () {
|
||||
const response = await fetch("/api/repo/fixture/file/page.shtml?download=1");
|
||||
expect(response.status).to.equal(200);
|
||||
expect(response.headers["content-disposition"]).to.include("attachment");
|
||||
expect(response.headers["x-content-type-options"]).to.equal("nosniff");
|
||||
});
|
||||
it("preserves content gates for HEAD", async function () {
|
||||
repo.options.pdf = false;
|
||||
expect((await fetch("/api/repo/fixture/file/private.pdf", "HEAD")).status).to.equal(403);
|
||||
|
||||
@@ -17,6 +17,12 @@ describe("file route scriptable-document detection", function () {
|
||||
"style.xsl",
|
||||
"transform.xslt",
|
||||
"archive.mhtml",
|
||||
"archive.mht",
|
||||
"page.shtml",
|
||||
"figures/plot.svgz",
|
||||
"schema.xsd",
|
||||
"schema.rng",
|
||||
"feed.atom",
|
||||
];
|
||||
scriptable.forEach((path) => {
|
||||
expect(isScriptableDocument(path), path).to.equal(true);
|
||||
@@ -26,6 +32,8 @@ describe("file route scriptable-document detection", function () {
|
||||
it("is case insensitive on the extension", function () {
|
||||
expect(isScriptableDocument("README.HTML")).to.equal(true);
|
||||
expect(isScriptableDocument("logo.SVG")).to.equal(true);
|
||||
expect(isScriptableDocument("logo.SVGZ")).to.equal(true);
|
||||
expect(isScriptableDocument("page.SHTML")).to.equal(true);
|
||||
});
|
||||
|
||||
it("leaves everything else alone", function () {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const { expect } = require("chai");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const vm = require("node:vm");
|
||||
const { JSDOM } = require("jsdom");
|
||||
|
||||
const read = file => fs.readFileSync(path.join(__dirname, "../public", file), "utf8");
|
||||
const loader = [...read("index.html").matchAll(/<script>([\s\S]*?)<\/script>/g)]
|
||||
.map(match => match[1]).find(script => script.includes("window.loadMermaid"));
|
||||
|
||||
describe("Mermaid untrusted diagram security", function () {
|
||||
this.timeout(10000);
|
||||
let dom, window;
|
||||
beforeEach(function () {
|
||||
dom = new JSDOM("<body></body>", { runScripts: "outside-only", url: "https://viewer.test/" });
|
||||
window = dom.window;
|
||||
window.SVGElement.prototype.getBBox = () => ({ x: 0, y: 0, width: 100, height: 20 });
|
||||
vm.runInContext(read("script/external/mermaid.min.js"), dom.getInternalVMContext());
|
||||
});
|
||||
afterEach(function () { dom.window.close(); });
|
||||
|
||||
it("lazy-loads with strict security and rejects diagram overrides and click links", async function () {
|
||||
window.eval(loader);
|
||||
window.loadMermaid();
|
||||
window.document.querySelector("script").onload();
|
||||
expect(window.mermaidInitialized).to.equal(true);
|
||||
expect(window.mermaid.mermaidAPI.getConfig().securityLevel).to.equal("strict");
|
||||
const result = await window.mermaid.render("hostile-diagram", `%%{init: {"securityLevel": "loose"}}%%
|
||||
flowchart TD
|
||||
A[Readme] --> B[Details]
|
||||
click A href "javascript:alert(document.domain)" "Open"
|
||||
click B call alert() "Callback"`);
|
||||
expect(result.svg).to.include("Readme");
|
||||
expect(result.svg).not.to.include("javascript:");
|
||||
window.document.body.innerHTML = result.svg;
|
||||
let callbacks = 0;
|
||||
window.alert = () => { callbacks++; };
|
||||
result.bindFunctions?.(window.document.body);
|
||||
expect(window.document.querySelector("a[href], a[xlink\\:href]")).to.equal(null);
|
||||
window.document.querySelectorAll(".node").forEach(node => {
|
||||
node.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(callbacks).to.equal(0);
|
||||
expect(window.mermaid.mermaidAPI.getConfig().securityLevel).to.equal("strict");
|
||||
});
|
||||
|
||||
it("initializes strict security through the extension fallback too", function () {
|
||||
const scheduled = [];
|
||||
window.setTimeout = callback => scheduled.push(callback);
|
||||
window.eval(read("script/external/marked-mermaid.js"));
|
||||
const extension = window.markedMermaid().extensions[0];
|
||||
extension.renderer({ text: "graph TD; A-->B" });
|
||||
scheduled.forEach(callback => callback());
|
||||
expect(window.mermaidInitialized).to.equal(true);
|
||||
expect(window.mermaid.mermaidAPI.getConfig().securityLevel).to.equal("strict");
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,45 @@ describe("repository activity dates", () => {
|
||||
expect(saved.settingsSavedAt).to.be.instanceOf(Date);
|
||||
expect(saved["options.expirationDate"]).to.be.instanceOf(Date);
|
||||
});
|
||||
for (const action of ["save", "extend"]) {
|
||||
for (const outcome of ["success", "failure", "concurrent removal"]) {
|
||||
it(`${action} restores atomically and handles enqueue ${outcome}`, async () => {
|
||||
const repo = repository(action === "save" ? "removed" : "expired");
|
||||
auth(repo);
|
||||
const stored = repo.model.toObject();
|
||||
let writes = 0, responded = false, queued = false;
|
||||
stub(Model, "updateOne", (filter, update) => ({ exec: async () => {
|
||||
if (!require("sift").default(filter)(stored)) return { matchedCount: 0 };
|
||||
// Mongoose omits undefined values from $set before sending it.
|
||||
for (const [key, value] of Object.entries(update.$set)) {
|
||||
if (value !== undefined) stored[key] = value;
|
||||
}
|
||||
writes++;
|
||||
return { matchedCount: 1 };
|
||||
} }));
|
||||
const failure = new Error("queue unavailable");
|
||||
stub(queue, "downloadQueue", { add: async () => {
|
||||
expect(responded).to.equal(false);
|
||||
expect(writes).to.equal(1);
|
||||
expect(stored.status).to.equal("preparing");
|
||||
expect(stored.anonymizeDate.getTime()).to.be.greaterThan(new Date("2026-01-02").getTime());
|
||||
if (outcome === "concurrent removal") stored.status = "removing";
|
||||
if (outcome !== "success") throw failure;
|
||||
queued = true;
|
||||
} });
|
||||
let caught;
|
||||
try {
|
||||
await handler("repository-private", action === "save" ? "/:repoId/" : "/:repoId/extend", "post")({ body: {
|
||||
repoId: repo.repoId, fullName: "owner/repo", source: { commit: "abc123", branch: "main" },
|
||||
terms: [], options: { expirationMode: "never" },
|
||||
} }, { json: () => { expect(queued).to.equal(true); responded = true; } });
|
||||
} catch (error) { caught = error; }
|
||||
expect(caught).to.equal(outcome === "success" ? undefined : failure);
|
||||
expect(responded).to.equal(outcome === "success");
|
||||
expect(stored.status).to.equal(outcome === "success" ? "preparing" : outcome === "failure" ? "error" : "removing");
|
||||
});
|
||||
}
|
||||
}
|
||||
it("returns publication and settings dates independently of the older source commit", async () => {
|
||||
const repo = repository(); auth(repo);
|
||||
repo.model.settingsSavedAt = new Date("2026-03-01");
|
||||
|
||||
@@ -103,6 +103,8 @@ describe("reconnecting a recreated repository", () => {
|
||||
expect(saved.update.$set.githubAccess.revision).not.to.equal("old");
|
||||
expect(saved.update.$set.source.repositoryId).to.equal("new-record");
|
||||
expect(saved.update.$set.source.commit).to.equal("abcdef");
|
||||
expect(saved.update.$set.status).to.equal("preparing");
|
||||
expect(saved.update.$set.statusDate).to.be.instanceOf(Date);
|
||||
expect(saved.update.$set.source.commitDate).to.deep.equal(new Date("2026-09-29T00:00:00Z"));
|
||||
expect(removals).to.deep.equal([{ accessRevision: "old" }]);
|
||||
expect(jobs[0][1]).to.deep.equal({ repoId: "recreated" });
|
||||
|
||||
+40
-3
@@ -190,6 +190,43 @@ describe("Vue 3 UI", function () {
|
||||
expect(ui.window.document.querySelector("#email-prompt-address").value).to.equal("owner@example.com");
|
||||
});
|
||||
|
||||
for (const code of ["repo_not_found", "token_expired", "github_app_reconnect_required"]) {
|
||||
it(`explains ${code} without exposing the source and retries after access recovers`, async () => {
|
||||
let recovered = false;
|
||||
ui = await browser("/r/access-test/hello.js", {
|
||||
"/api/repo/access-test/file/hello.js": () => recovered ? "recovered content" : {
|
||||
__status: 404, body: { error: code, detail: "private-owner/private-source", token: "secret" },
|
||||
},
|
||||
});
|
||||
await delay(100);
|
||||
const panel = ui.window.document.querySelector(".viewer-error");
|
||||
expect(panel?.textContent).to.include("GitHub access needs attention");
|
||||
expect(panel.textContent).to.include("You do not need to sign in");
|
||||
expect(panel.textContent).to.include("Keep the existing anonymous URL");
|
||||
expect(panel.textContent).not.to.include("private-owner");
|
||||
expect(panel.textContent).not.to.include("secret");
|
||||
recovered = true;
|
||||
panel.querySelector("button").click();
|
||||
await delay(150);
|
||||
expect(ui.window.document.querySelector(".viewer-error")).to.equal(null);
|
||||
expect(ui.requests.filter(request => request.url.pathname.endsWith("/file/hello.js"))).to.have.length(2);
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
}
|
||||
|
||||
it("offers a safe retry for an unrecognized gateway response", async () => {
|
||||
ui = await browser("/r/access-test/hello.js", {
|
||||
"/api/repo/access-test/options": { __status: 403, body: "<html>private diagnostic</html>" },
|
||||
});
|
||||
await delay(100);
|
||||
const panel = ui.window.document.querySelector(".viewer-error");
|
||||
expect(panel?.textContent).to.include("We could not load this content");
|
||||
expect(panel.textContent).not.to.include("private diagnostic");
|
||||
expect(panel.textContent).not.to.include("GitHub access needs attention");
|
||||
expect(panel.querySelector("button").textContent).to.equal("Try again");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
const repositorySource = {
|
||||
"/api/user": { username: "owner" },
|
||||
"/api/user/default": { terms: ["Default Author"], options: {} },
|
||||
@@ -1161,13 +1198,13 @@ describe("Vue 3 UI", function () {
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
for (const [code, message] of [["repo_not_found", "The repository was not found"], ["untranslated_failure", "Unknown error, contact the admin."]]) {
|
||||
for (const [code, message] of [["repo_not_found", "GitHub access needs attention"], ["untranslated_failure", "We could not load this content"]]) {
|
||||
it("shows the " + code + " explorer error in a card without file actions", async function () {
|
||||
ui = await browser("/r/gone/", { "/api/repo/gone/options": { __status: 404, body: { error: code } } });
|
||||
const card = ui.window.document.querySelector(".file-error-card");
|
||||
expect(card.textContent).to.include("This content can").and.include(message);
|
||||
expect(card.textContent).to.include(message);
|
||||
expect(card.textContent).not.to.include("ERRORS.");
|
||||
expect(card.querySelector('a[href="/"]')).to.not.equal(null);
|
||||
expect(card.querySelector(code === "repo_not_found" ? 'a[href="/dashboard"]' : 'a[href="/"]')).to.not.equal(null);
|
||||
expect(ui.window.document.querySelector(".status-bar").style.display).to.equal("none");
|
||||
expect(ui.errors).to.deep.equal([]);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user