chore(deps): upgrade dependencies and remove production vulnerabilities

This commit is contained in:
Thomas Durieux committed 2026-10-08 10:21:00 +00:00
1 parent 9435a99b6c
commit fd0dd0adbd
29 files changed
+5220 -21129

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
/* global fetch */
const { expect } = require("chai");
const express = require("express");
const session = require("express-session");
const { createClient } = require("redis");
const { RedisStore: SessionStore } = require("connect-redis");
const { RedisStore: RateLimitStore } = require("rate-limit-redis");
const { rateLimit } = require("express-rate-limit");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const path = require("node:path");
const port = Number(process.env.PERFORMANCE_REDIS_PORT);
(port ? describe : describe.skip)("upgraded Redis stores and session cleanup", function () {
this.timeout(15000);
let redis, server, base;
const prefix = `dependency-test:${process.pid}:`;
before(async function () {
redis = createClient({ socket: { host: "127.0.0.1", port, reconnectStrategy: false } });
redis.on("error", () => {});
await redis.connect();
const app = express();
app.use(session({ secret: "dependency-test", resave: false, saveUninitialized: false,
store: new SessionStore({ client: redis, prefix: `${prefix}session:` }) }));
app.get("/session", (req, res) => {
req.session.visits = (req.session.visits || 0) + 1;
res.json({ visits: req.session.visits });
});
const limiter = () => rateLimit({ windowMs: 60000, limit: 1, keyGenerator: () => "client",
store: new RateLimitStore({ prefix: `${prefix}limit:`, sendCommand: (...args) => redis.sendCommand(args) }) });
app.get("/first", limiter(), (_req, res) => res.send("ok"));
app.get("/second", limiter(), (_req, res) => res.send("ok"));
await new Promise(resolve => { server = app.listen(0, "127.0.0.1", resolve); });
base = `http://127.0.0.1:${server.address().port}`;
});
after(async function () {
if (server) {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}
if (redis?.isOpen) {
for await (const keys of redis.scanIterator({ MATCH: `${prefix}*` })) {
if (keys.length) await redis.del(keys);
}
await redis.del([`anoGH_session:${prefix}legacy`, `anoGH_session:${prefix}current`]);
await redis.close();
}
});
it("persists sessions across HTTP requests", async function () {
const first = await fetch(`${base}/session`);
expect(await first.json()).to.deep.equal({ visits: 1 });
const cookie = first.headers.get("set-cookie").split(";")[0];
const second = await fetch(`${base}/session`, { headers: { Cookie: cookie } });
expect(await second.json()).to.deep.equal({ visits: 2 });
});
it("shares a request quota between separate Redis store instances", async function () {
expect((await fetch(`${base}/first`)).status).to.equal(200);
expect((await fetch(`${base}/second`)).status).to.equal(429);
});
it("scans session batches, preserves current sessions and applies legacy cleanup", async function () {
const legacy = `anoGH_session:${prefix}legacy`, current = `anoGH_session:${prefix}current`;
await redis.set(legacy, JSON.stringify({ passport: { user: { id: "legacy" } } }));
await redis.set(current, JSON.stringify({ passport: { user: "current" } }));
const run = async args => {
const result = await promisify(execFile)(process.execPath,
["-r", "ts-node/register", "src/scripts/purge-legacy-sessions.ts", ...args], {
cwd: path.resolve(__dirname, ".."), timeout: 10000,
env: { ...process.env, NODE_ENV: "test", REDIS_HOSTNAME: "127.0.0.1", REDIS_PORT: String(port) },
});
return JSON.parse(result.stdout.trim().split("\n").at(-1));
};
expect(await run([])).to.deep.equal({ found: 1, removed: 0 });
expect(await redis.exists(legacy)).to.equal(1);
expect(await run(["--apply"])).to.deep.equal({ found: 1, removed: 1 });
expect(await redis.exists(legacy)).to.equal(0);
expect(await redis.get(current)).to.equal(JSON.stringify({ passport: { user: "current" } }));
});
});
+86
View File
@@ -0,0 +1,86 @@
/* global fetch */
const { expect } = require("chai");
const http = require("node:http");
const net = require("node:net");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { once } = require("node:events");
const { setTimeout, clearTimeout } = require("node:timers");
describe("development proxy", function () {
this.timeout(15000);
let upstream, child, base, target;
before(async function () {
upstream = http.createServer((req, res) => {
if (req.url === "/api/headers") {
res.setHeader("content-type", "application/json");
return res.end(JSON.stringify({ origin: req.headers.origin, referer: req.headers.referer }));
}
res.setHeader("set-cookie", "test=value; Domain=localhost; Secure; SameSite=None; Path=/");
if (req.url === "/api/redirect") {
res.writeHead(302, { location: `${target}/api/next` });
return res.end();
}
res.setHeader("content-type", "text/html");
res.end(`<a href="${target}/api/next">next</a>`);
});
await new Promise(resolve => upstream.listen(0, "127.0.0.1", resolve));
target = `http://127.0.0.1:${upstream.address().port}`;
const socket = net.createServer();
await new Promise(resolve => socket.listen(0, "127.0.0.1", resolve));
const port = socket.address().port;
await new Promise(resolve => socket.close(resolve));
base = `http://127.0.0.1:${port}`;
child = spawn(process.execPath, ["scripts/dev-proxy.js"], {
cwd: path.resolve(__dirname, ".."), env: { ...process.env, UPSTREAM: target, PORT: String(port) },
stdio: ["ignore", "pipe", "pipe"],
});
await new Promise((resolve, reject) => {
let output = "", errors = "";
const timer = setTimeout(() => reject(new Error("Development proxy startup timed out")), 10000);
child.stderr.on("data", chunk => { errors += chunk; });
child.stdout.on("data", chunk => {
output += chunk;
if (output.includes("dev-proxy")) { clearTimeout(timer); resolve(); }
});
child.once("exit", code => { clearTimeout(timer); reject(new Error(`Proxy exited with ${code}: ${errors}`)); });
});
});
after(async function () {
if (child && child.exitCode === null && child.signalCode === null) {
const exited = once(child, "exit");
child.kill();
await exited;
}
if (upstream) {
upstream.closeAllConnections();
await new Promise(resolve => upstream.close(resolve));
}
});
it("forwards the request hooks to the upstream", async function () {
const response = await fetch(`${base}/api/headers`);
expect(response.status).to.equal(200);
expect(await response.json()).to.deep.equal({ origin: target, referer: `${target}/api/headers` });
});
it("rewrites HTML, cookies and redirects for localhost", async function () {
const response = await fetch(`${base}/api/html`);
expect(await response.text()).to.equal('<a href="/api/next">next</a>');
const cookie = response.headers.get("set-cookie");
expect(cookie).to.include("SameSite=Lax");
expect(cookie).not.to.match(/Secure|Domain=/i);
const redirect = await fetch(`${base}/api/redirect`, { redirect: "manual" });
expect(redirect.status).to.equal(302);
expect(redirect.headers.get("location")).to.equal("/api/next");
});
it("serves the local UI with resolved asset names", async function () {
const response = await fetch(base);
expect(response.status).to.equal(200);
const body = await response.text();
expect(body).to.include('id="app"');
expect(body).not.to.include("__CORE_JS__");
expect(body).not.to.include("__VENDOR_JS__");
});
});
+3 -3
View File
@@ -65,13 +65,13 @@ describe("download headers and website revalidation", function () {
expect(second.headers["content-security-policy"]).to.include("sandbox");
});
it("renders Markdown again after a transient failure served the raw fallback", async function () {
const marked = require("marked"), render = marked.marked;
const marked = require("marked").marked, render = marked.parse;
let first;
try {
marked.marked = () => { throw Error("transient rendering failure"); };
marked.parse = () => { throw Error("transient rendering failure"); };
first = await fetch("/w/fixture/page.md");
expect(first.status).to.equal(200); expect(first.body).to.equal("fixture content");
} finally { marked.marked = render; }
} finally { marked.parse = render; }
const recovered = await fetch("/w/fixture/page.md", "GET", { "If-None-Match": first.headers.etag });
expect(recovered.status).to.equal(200); expect(recovered.body).to.include("Anonymized page");
expect(recovered.headers.etag).not.to.equal(first.headers.etag);
+1 -1
View File
@@ -34,7 +34,7 @@ const sanitizeOptions = {
* sanitizeHtml(marked(content, opts), sanitizeOptions)
*/
function renderAndSanitize(markdown) {
const raw = marked(markdown, { headerIds: false, mangle: false });
const raw = marked(markdown, { async: false });
return sanitizeHtml(raw, sanitizeOptions);
}
@@ -18,12 +18,12 @@ const port = Number(process.env.PERFORMANCE_REDIS_PORT);
});
beforeEach(async function () {
const keys = [];
for await (const key of redis.scanIterator({ MATCH: "performance:v1:*" })) keys.push(key);
for await (const batch of redis.scanIterator({ MATCH: "performance:v1:*" })) keys.push(...batch);
if (keys.length) await redis.del(keys);
requestMetrics.drain();
});
afterEach(async function () { if (stop) { await stop(); stop = null; } requestMetrics.drain(); });
after(async function () { await redis.disconnect(); config.REDIS_HOSTNAME = previous.host; config.REDIS_PORT = previous.port; });
after(async function () { await redis.close(); config.REDIS_HOSTNAME = previous.host; config.REDIS_PORT = previous.port; });
async function start(service) {
stop = startPerformanceMonitoring(service, () => ({ running: 0, waiting: 0 }));
const deadline = Date.now() + 4000;
+1 -1
View File
@@ -26,7 +26,7 @@ const { setTimeout: delay } = require("node:timers/promises");
redis = createClient({ socket: { host: "127.0.0.1", port: config.REDIS_PORT } });
await redis.connect();
});
after(async () => { await closeCacheRedis(); await redis.quit(); Object.assign(config, previousRedis); await fs.rm(root, { force: true, recursive: true }); });
after(async () => { await closeCacheRedis(); await redis.close(); Object.assign(config, previousRedis); await fs.rm(root, { force: true, recursive: true }); });
it("downloads and publishes once across two streamer processes and ten callers", async () => {
let producers = 0;
const children = Array.from({ length: 2 }, () => fork(path.join(__dirname, "fixtures/performance-cache-worker.js"), [], { stdio: ["ignore", "ignore", "ignore", "ipc"], env: { ...process.env,
@@ -32,8 +32,8 @@ const uri = process.env.PERFORMANCE_MONGO_URI;
beforeEach(async () => { await Repo.deleteMany({}); });
const missing = async file => { try { await fs.access(file); return false; } catch (error) { if (error.code === "ENOENT") return true; throw error; } };
async function waitFor(check) {
const deadline = Date.now() + 5000;
async function waitFor(check, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (!await check()) { if (Date.now() >= deadline) throw Error("Streamer cleanup did not finish"); await new Promise(resolve => setTimeout(resolve, 20)); }
}
async function launch(invalidCredentials = false, holdConnect = false) {
@@ -78,7 +78,9 @@ const uri = process.env.PERFORMANCE_MONGO_URI;
it("connects before serving and rejects a fill published after expiration in the actual streamer entrypoint", async () => {
const runtime = await launch(false, true); let pending;
try {
await waitFor(() => runtime.messages.some(message => message.connectionHeld));
// Loading TypeScript and the Redis client in a fresh process can take
// longer than the cleanup deadline used after the streamer starts.
await waitFor(() => runtime.messages.some(message => message.connectionHeld), 10000);
await fetch(runtime.url + "/healthcheck").then(() => { throw Error("Streamer served before connecting"); }, error => expect(error).to.be.instanceOf(Error));
runtime.child.send("connect");
await runtime.ready;
+1 -1
View File
@@ -110,7 +110,7 @@ describe("large anonymization resources", function () {
});
it("bounds an unresponsive Redis command and disconnects its socket", async () => {
let disconnected = false, error;
const client = { isOpen: true, disconnect: async () => { disconnected = true; } };
const client = { isOpen: true, destroy: () => { disconnected = true; } };
try { await cacheCommand(new Promise(() => {}), client, 20); } catch (failure) { error = failure; }
expect(error.message).to.equal("cache_command_timeout"); expect(disconnected).to.equal(true);
});
+47
View File
@@ -0,0 +1,47 @@
/* global fetch */
const { expect } = require("chai");
const express = require("express");
const { rateLimit } = require("express-rate-limit");
require("ts-node/register/transpile-only");
const { requestRateLimitKey } = require("../src/server/rate-limit-key");
describe("rate limiter client addresses", function () {
const key = (ip, headers = {}, remoteAddress = "127.0.0.1") =>
requestRateLimitKey({ ip, headers, socket: { remoteAddress } });
it("normalizes IPv4 ports and mapped IPv6 without truncating native IPv6", function () {
expect(key("192.0.2.1:1234")).to.equal("192.0.2.1");
expect(key("::ffff:192.0.2.1")).to.equal("192.0.2.1");
expect(key("2001:db8:abcd:1200::1234")).to.equal("2001:db8:abcd:1200::/56");
expect(key("2001:db8:abcd:1300::1234")).not.to.equal(key("2001:db8:abcd:1200::1234"));
});
it("accepts the visitor header only for a Cloudflare edge address", function () {
expect(key("192.0.2.1", { "cf-connecting-ip": "198.51.100.1" })).to.equal("192.0.2.1");
expect(key("104.16.0.1", { "cf-connecting-ip": " 198.51.100.1 " })).to.equal("198.51.100.1");
expect(key("104.16.0.1", { "cf-connecting-ip": " " })).to.equal("104.16.0.1");
expect(key(undefined, {}, "::ffff:192.0.2.1")).to.equal("192.0.2.1");
});
it("shares limits across IPv6 addresses within a subnet and ignores forged Cloudflare headers", async function () {
const app = express();
app.set("trust proxy", "loopback");
app.use(rateLimit({ windowMs: 60000, limit: 1, keyGenerator: requestRateLimitKey }));
app.get("/", (_req, res) => res.send("ok"));
let server;
await new Promise(resolve => { server = app.listen(0, "127.0.0.1", resolve); });
const request = (ip, visitor) => fetch(`http://127.0.0.1:${server.address().port}/`, {
headers: { "x-forwarded-for": ip, "cf-connecting-ip": visitor },
});
try {
expect((await request("2001:db8:abcd:1200::1", "198.51.100.1")).status).to.equal(200);
expect((await request("2001:db8:abcd:12ff::2", "198.51.100.2")).status).to.equal(429);
expect((await request("2001:db8:abcd:1300::1", "198.51.100.1")).status).to.equal(200);
expect((await request("192.0.2.1", "198.51.100.1")).status).to.equal(200);
expect((await request("::ffff:192.0.2.1", "198.51.100.2")).status).to.equal(429);
} finally {
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}
});
});
+12 -9
View File
@@ -1,9 +1,10 @@
const { expect } = require("chai");
const { JSDOM, VirtualConsole, ResourceLoader } = require("jsdom");
const { JSDOM, VirtualConsole, requestInterceptor } = require("jsdom");
const fs = require("fs");
const path = require("path");
const { URL } = require("node:url");
const { setTimeout: delay } = require("node:timers/promises");
const { Response } = globalThis;
const publicDir = path.join(__dirname, "../public");
const bundles = ["core.min.js", "vendor.min.js"].map(name => fs.readFileSync(path.join(publicDir, "script", name), "utf8"));
@@ -12,19 +13,21 @@ async function browser(route = "/", overrides = {}, storage = {}) {
const errors = [], requests = [], assets = [];
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", error => {
if (!error.message.includes("navigation (except hash changes)")) errors.push(error.message);
if (error.type === "not-implemented" && error.message === "Not implemented: navigation to another Document") return;
errors.push(error.message);
});
virtualConsole.on("error", error => errors.push(error?.message || String(error)));
const dom = new JSDOM('<!doctype html><html><head></head><body><div id="app"></div></body></html>', {
resources: new class extends ResourceLoader {
fetch(url) {
resources: {
interceptors: [requestInterceptor(request => {
const url = request.url;
assets.push(new URL(url).pathname);
if (/\/pdf\.[a-f0-9]+\.min\.js$/.test(new URL(url).pathname) && dom.window.pdfjsLib) return Promise.resolve(Buffer.from(""));
if (/\/pdf\.[a-f0-9]+\.min\.js$/.test(new URL(url).pathname) && dom.window.pdfjsLib) return new Response("");
const pathname = new URL(url).pathname.replace(/\.[a-f0-9]{10}\.min\.js$/, ".min.js");
if (pathname.startsWith("/script/")) return Promise.resolve(fs.readFileSync(path.join(publicDir, pathname)));
return null;
}
}(),
if (pathname.startsWith("/script/")) return new Response(fs.readFileSync(path.join(publicDir, pathname)), { headers: { "content-type": "application/javascript" } });
return new Response(null, { status: 204 });
})],
},
url: "http://localhost" + route, runScripts: "dangerously", pretendToBeVisual: true, virtualConsole,
});
const window = dom.window;