From 5cc65d87cd666df436c411f2bf43af4fd2a39f96 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 15 Oct 2026 22:18:48 -0500 Subject: [PATCH] feat(sdk): let a plugin raise its public route rate limit --- package-lock.json | 2 +- packages/plugin-sdk/CHANGELOG.md | 6 ++++++ packages/plugin-sdk/PLUGIN-GUIDE.md | 5 +++-- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/backend.ts | 7 +++++++ src/backend/plugins/http.ts | 18 ++++++++++++++---- src/backend/tests/plugins/http.test.ts | 13 +++++++++++++ 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2e3834bad..adeec1a7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15058,7 +15058,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.7", + "version": "1.0.8", "license": "Apache-2.0", "dependencies": { "@deadendjs/swagger-jsdoc": "^8.1.2", diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index 89a59deba..d3bb9201e 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.0.8 + +### Added + +- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page + ## 1.0.7 ### Fixed diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 9647223eb..82586569b 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.7 | Plugin API 1.2.0 +SDK 1.0.8 | Plugin API 1.2.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -283,7 +283,8 @@ router.post("/notes", async (req, res) => { - The `@openapi` path is the full path, starting with `/plugin-api//`. `build` turns these into `dist/openapi.json` and the docs site renders them. - `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes - have no actor: check their token yourself and rate limit them. + have no actor: check their token yourself. Core allows 120 public requests a minute per address; + `publicRateLimit` raises that for a proxied page that loads its own assets. - `ctx.http.baseUrl(req)` is the public address, for callback URLs. - `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check `conn.isDataUnlocked()` per message. diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 6c6e5e2d3..77c7a5548 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.7", + "version": "1.0.8", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 6ea70c0f8..19c1bf0dd 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -357,6 +357,13 @@ export interface PluginRouterOptions { * (multer, a raw body for signature checking, a proxied stream). */ rawBody?: boolean; + /** + * Requests per minute one address may make to this plugin's public routes. + * Defaults to 120, which a proxied page loading its own scripts and images + * can pass on its own. Capped at 10000, and an admin's + * TERMIX_PLUGIN_PUBLIC_RATE_LIMIT wins over it. + */ + publicRateLimit?: number; } /** diff --git a/src/backend/plugins/http.ts b/src/backend/plugins/http.ts index 695a4791b..7ebab8745 100644 --- a/src/backend/plugins/http.ts +++ b/src/backend/plugins/http.ts @@ -42,9 +42,16 @@ const routers = new Map(); * Requests per minute one address may make to one plugin's public routes. * They run without a login, so they are the cheap way to hammer a plugin. */ -function publicRateLimit(): number { +const MAX_PLUGIN_PUBLIC_RATE_LIMIT = 10_000; + +/** The admin's limit when set, else the plugin's own, else 120. */ +function publicRateLimit(pluginLimit?: number): number { const parsed = Number(process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT); - return Number.isFinite(parsed) && parsed > 0 ? parsed : 120; + if (Number.isFinite(parsed) && parsed > 0) return parsed; + if (Number.isFinite(pluginLimit) && (pluginLimit as number) > 0) { + return Math.min(pluginLimit as number, MAX_PLUGIN_PUBLIC_RATE_LIMIT); + } + return 120; } const PUBLIC_RATE_WINDOW_MS = 60_000; const publicHits = new Map(); @@ -54,6 +61,7 @@ export function overPublicRateLimit( pluginId: string, ip: string, now = Date.now(), + pluginLimit?: number, ): boolean { if (publicHits.size > 10_000) { for (const [hitKey, entry] of publicHits) { @@ -67,7 +75,7 @@ export function overPublicRateLimit( return false; } entry.count++; - return entry.count > publicRateLimit(); + return entry.count > publicRateLimit(pluginLimit); } /** Test seam. */ @@ -319,7 +327,9 @@ export function createPluginRouter({ outer.use((req: Request, res: Response, next: NextFunction) => { if (isPublicRequest(req, publicPaths, inner)) { const ip = req.ip || req.socket?.remoteAddress || "unknown"; - if (overPublicRateLimit(pluginId, ip)) { + if ( + overPublicRateLimit(pluginId, ip, Date.now(), options?.publicRateLimit) + ) { res.status(429).json({ error: "Too many requests" }); return; } diff --git a/src/backend/tests/plugins/http.test.ts b/src/backend/tests/plugins/http.test.ts index a947192bb..5b757303c 100644 --- a/src/backend/tests/plugins/http.test.ts +++ b/src/backend/tests/plugins/http.test.ts @@ -461,6 +461,19 @@ describe("public route rate limit", () => { expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false); }); + it("lets a plugin raise its own limit, capped, unless the admin set one", () => { + const hits = (limit: number, count: number) => + Array.from({ length: count }, () => + http.overPublicRateLimit("proxy", "ip", 0, limit), + ).filter(Boolean).length; + expect(hits(500, 500)).toBe(0); + http.resetPublicRateLimits(); + expect(hits(50_000, 10_001)).toBe(1); + http.resetPublicRateLimits(); + process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "2"; + expect(hits(500, 3)).toBe(1); + }); + it("starts a new window after a minute", () => { process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1"; expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);