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

+9 -2
View File
@@ -21,8 +21,8 @@ function decodePathSegment(segment: string): string {
}
}
// Extensions the browser will execute script from when it renders the
// response as a document (directly, or via the "Raw" action).
// Keep explicit fallbacks for markup types not consistently mapped by MIME
// databases. MIME classification below also covers aliases such as svgz/shtml.
const SCRIPTABLE_EXTENSIONS = new Set([
"html",
"htm",
@@ -33,10 +33,16 @@ const SCRIPTABLE_EXTENSIONS = new Set([
"xsl",
"xslt",
"mhtml",
"mht",
]);
export function isScriptableDocument(anonymizedPath: string): boolean {
const name = anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1);
const mime = lookup(name);
if (mime && (
mime === "text/html" || mime === "text/xml" || mime === "application/xml" ||
mime.endsWith("+xml") || mime === "message/rfc822" || mime === "multipart/related"
)) return true;
const dot = name.lastIndexOf(".");
if (dot < 0) return false;
return SCRIPTABLE_EXTENSIONS.has(name.substring(dot + 1).toLowerCase());
@@ -115,6 +121,7 @@ router.get(
object: f,
});
}
res.header("X-Content-Type-Options", "nosniff");
if (req.query.download) {
res.attachment(
anonymizedPath.substring(anonymizedPath.lastIndexOf("/") + 1)
+35 -14
View File
@@ -33,6 +33,24 @@ const router = express.Router();
// user needs to be connected for all user API
router.use(ensureAuthenticated);
async function enqueueSavedSnapshot(model: IAnonymizedRepositoryDocument) {
try {
await downloadQueue.add(model.repoId, { repoId: model.repoId }, {
jobId: `repo-${model.repoId}`, attempts: 3,
});
} catch (error) {
// Leave a failed enqueue retryable without overwriting a worker or a
// lifecycle change that happened after this save.
await AnonymizedRepositoryModel.updateOne({
_id: model._id, status: RepositoryStatus.PREPARING,
statusDate: model.statusDate, anonymizeDate: model.anonymizeDate,
"githubAccess.revision": model.githubAccess?.revision || { $exists: false },
}, { $set: { status: RepositoryStatus.ERROR, statusDate: new Date(),
statusMessage: "preparation_interrupted" } }).exec();
throw error;
}
}
async function previewToken(req: express.Request) {
const user = await getUser(req);
if (typeof req.query.anonymizedRepoId === "string") {
@@ -185,15 +203,18 @@ router.post(
repo.model.options.expirationDate
);
const reactivating = repo.status === RepositoryStatus.EXPIRED;
const updates: Record<string, Date> = {
const updates: Record<string, Date | string | null> = {
"options.expirationDate": newExpiration,
settingsSavedAt: new Date(),
};
repo.model.options.expirationDate = newExpiration;
repo.model.settingsSavedAt = updates.settingsSavedAt;
repo.model.settingsSavedAt = updates.settingsSavedAt as Date;
if (reactivating) {
repo.model.anonymizeDate = new Date();
updates.anonymizeDate = repo.model.anonymizeDate;
updates.status = RepositoryStatus.PREPARING;
updates.statusDate = new Date();
updates.statusMessage = null;
}
const saved = await AnonymizedRepositoryModel.updateOne(
{ _id: repo.model._id, status: repo.status, statusDate: repo.model.statusDate },
@@ -207,12 +228,10 @@ router.post(
// directly instead of asking GitHub for the latest branch head first;
// that lookup can fail after the new date has already been persisted
// and leave the repository stuck in the expired state.
await repo.updateStatus(RepositoryStatus.PREPARING);
await downloadQueue.add(
repo.repoId,
{ repoId: repo.repoId },
{ jobId: `repo-${repo.repoId}`, attempts: 3 }
);
repo.model.status = RepositoryStatus.PREPARING;
repo.model.statusDate = updates.statusDate as Date;
repo.model.statusMessage = undefined;
await enqueueSavedSnapshot(repo.model);
}
res.json({ status: repo.status, expirationDate: newExpiration });
} catch (error) {
@@ -610,6 +629,9 @@ router.post(
}
}
repo.model.conference = repoUpdate.conference;
const preparation = sourceChanged || reactivating ? {
status: RepositoryStatus.PREPARING, statusDate: new Date(), statusMessage: null,
} : undefined;
const saved = await AnonymizedRepositoryModel.updateOne(
{ _id: repo.model._id, status: repo.status, statusDate: repo.model.statusDate,
"githubAccess.revision": previousAccessRevision || { $exists: false } },
@@ -621,6 +643,7 @@ router.post(
githubAccess: repo.model.githubAccess,
conference: repo.model.conference,
anonymizeDate: repo.model.anonymizeDate,
...preparation,
},
}
).exec();
@@ -630,13 +653,11 @@ router.post(
return res.json({ status: repo.status });
}
await repo.updateStatus(RepositoryStatus.PREPARING);
repo.model.status = preparation!.status;
repo.model.statusDate = preparation!.statusDate;
repo.model.statusMessage = undefined;
await enqueueSavedSnapshot(repo.model);
res.json({ status: repo.status });
await downloadQueue.add(
repo.repoId,
{ repoId: repo.repoId },
{ jobId: `repo-${repo.repoId}` }
);
} catch (error) {
return handleError(error, res, req);
}