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:
Thomas Durieux committed 2026-11-02 05:50:34 +00:00
1 parent 772e40debd
commit a301c46a94
21 files changed
+1048 -1051

No files matched your search

+43
View File
@@ -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 });
}
});
});
+29
View File
@@ -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);
+8
View File
@@ -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 () {
+57
View File
@@ -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");
});
});
+39
View File
@@ -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");
+2
View File
@@ -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
View File
@@ -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([]);
});