Files
indiekit-server/scripts/patch-indiekit-routes-rate-limits.mjs

184 lines
5.7 KiB
JavaScript

import { access, readFile, writeFile } from "node:fs/promises";
const candidates = [
"node_modules/@indiekit/indiekit/lib/routes.js",
];
const patchMarker = "const sessionLimit = rateLimit({";
const oldLimitBlock = `const router = express.Router();
const limit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 250,
standardHeaders: true,
legacyHeaders: false,
validate: false,
});`;
const newLimitBlock = `const router = express.Router();
// Strict rate limiter for session/auth routes (brute force protection)
const sessionLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50,
standardHeaders: true,
legacyHeaders: false,
validate: false,
});
// Generous rate limiter for public API endpoints (read-only data)
const apiLimit = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000,
standardHeaders: true,
legacyHeaders: false,
validate: false,
});`;
const replacements = [
{
label: "session login rate limit",
from: ' router.get("/session/login", limit, sessionController.login);',
to: ' router.get("/session/login", sessionLimit, sessionController.login);',
},
{
label: "session post rate limit",
from: ' router.post("/session/login", limit, indieauth.login());',
to: ' router.post("/session/login", sessionLimit, indieauth.login());',
},
{
label: "session auth rate limit",
from: ' router.get("/session/auth", limit, indieauth.authorize());',
to: ' router.get("/session/auth", sessionLimit, indieauth.authorize());',
},
{
label: "public _routes rate limit",
from: " router.use(endpoint.mountPath, limit, endpoint._routes(Indiekit));",
to: " router.use(endpoint.mountPath, apiLimit, endpoint._routes(Indiekit));",
},
{
label: "public routesPublic rate limit",
from: ` if (endpoint.mountPath && endpoint.routesPublic) {
router.use(endpoint.mountPath, limit, endpoint.routesPublic);
}`,
to: ` if (endpoint.mountPath && endpoint.routesPublic) {
// Skip rate limiting for root-mounted endpoints (mountPath "/") because
// router.use("/", apiLimit, ...) matches ALL routes, applying the rate
// limiter globally.
if (endpoint.mountPath === "/") {
router.use(endpoint.mountPath, endpoint.routesPublic);
} else {
router.use(endpoint.mountPath, apiLimit, endpoint.routesPublic);
}
}`,
},
{
label: "well-known rate limit",
from: ' router.use("/.well-known/", limit, endpoint.routesWellKnown);',
to: ' router.use("/.well-known/", apiLimit, endpoint.routesWellKnown);',
},
{
label: "content negotiation routes",
from: " // Authenticate subsequent requests",
to: ` // Content negotiation routes - serves ActivityPub JSON-LD for post URLs
// and handles NodeInfo data at /nodeinfo/2.1. Mounted at root before auth
// so unauthenticated AP clients can fetch post representations.
for (const endpoint of endpoints) {
if (endpoint.contentNegotiationRoutes) {
router.use("/", endpoint.contentNegotiationRoutes);
}
}
// Authenticate subsequent requests`,
},
{
label: "plugin list limit removal",
from: ' router.get("/plugins", limit, pluginController.list);',
to: ' router.get("/plugins", pluginController.list);',
},
{
label: "plugin view limit removal",
from: ' router.get("/plugins/:pluginId", limit, pluginController.view);',
to: ' router.get("/plugins/:pluginId", pluginController.view);',
},
{
label: "status limit removal",
from: ' router.get("/status", limit, statusController.viewStatus);',
to: ' router.get("/status", statusController.viewStatus);',
},
{
label: "authenticated endpoint limit removal",
from: " router.use(endpoint.mountPath, limit, endpoint.routes);",
to: " router.use(endpoint.mountPath, endpoint.routes);",
},
];
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
let checked = 0;
let patched = 0;
for (const filePath of candidates) {
if (!(await exists(filePath))) {
continue;
}
checked += 1;
const source = await readFile(filePath, "utf8");
if (source.includes(patchMarker)) {
continue;
}
if (!source.includes(oldLimitBlock)) {
console.warn(
`[postinstall] Skipping routes patch for ${filePath}: upstream format changed`,
);
continue;
}
let updated = source.replace(oldLimitBlock, newLimitBlock);
for (const replacement of replacements) {
if (updated.includes(replacement.from)) {
updated = updated.replace(replacement.from, replacement.to);
} else {
console.warn(
`[postinstall] routes patch skipped section (${replacement.label}) in ${filePath}`,
);
}
}
const looksPatched =
updated.includes("const sessionLimit = rateLimit({") &&
updated.includes("const apiLimit = rateLimit({") &&
updated.includes('router.get("/session/login", sessionLimit, sessionController.login);') &&
updated.includes("router.use(endpoint.mountPath, endpoint.routes);") &&
!updated.includes('router.get("/session/login", limit, sessionController.login);') &&
!updated.includes("router.use(endpoint.mountPath, limit, endpoint.routes);");
if (!looksPatched) {
console.warn(
`[postinstall] Skipping routes patch for ${filePath}: patch validation failed`,
);
continue;
}
await writeFile(filePath, updated, "utf8");
patched += 1;
}
if (checked === 0) {
console.log("[postinstall] No indiekit routes files found");
} else if (patched === 0) {
console.log("[postinstall] indiekit routes rate-limit patch already applied");
} else {
console.log(`[postinstall] Patched indiekit routes rate limits in ${patched} file(s)`);
}