mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat(sdk): let a plugin raise its public route rate limit
This commit is contained in:
1 parent
e166994ec4
commit
5cc65d87cd
7 files changed
+45
-8
No files matched your search
Generated
+1
-1
@@ -15058,7 +15058,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 1.0.8
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
|
||||||
|
|
||||||
## 1.0.7
|
## 1.0.7
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Termix plugin guide
|
# 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
|
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.
|
`@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/<id>/`. `build` turns these into
|
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
|
||||||
`dist/openapi.json` and the docs site renders them.
|
`dist/openapi.json` and the docs site renders them.
|
||||||
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
|
- `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.http.baseUrl(req)` is the public address, for callback URLs.
|
||||||
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
|
||||||
`conn.isDataUnlocked()` per message.
|
`conn.isDataUnlocked()` per message.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"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.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -357,6 +357,13 @@ export interface PluginRouterOptions {
|
|||||||
* (multer, a raw body for signature checking, a proxied stream).
|
* (multer, a raw body for signature checking, a proxied stream).
|
||||||
*/
|
*/
|
||||||
rawBody?: boolean;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,9 +42,16 @@ const routers = new Map<string, Router>();
|
|||||||
* Requests per minute one address may make to one plugin's public routes.
|
* 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.
|
* 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);
|
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 PUBLIC_RATE_WINDOW_MS = 60_000;
|
||||||
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
const publicHits = new Map<string, { count: number; resetAt: number }>();
|
||||||
@@ -54,6 +61,7 @@ export function overPublicRateLimit(
|
|||||||
pluginId: string,
|
pluginId: string,
|
||||||
ip: string,
|
ip: string,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
|
pluginLimit?: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (publicHits.size > 10_000) {
|
if (publicHits.size > 10_000) {
|
||||||
for (const [hitKey, entry] of publicHits) {
|
for (const [hitKey, entry] of publicHits) {
|
||||||
@@ -67,7 +75,7 @@ export function overPublicRateLimit(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
entry.count++;
|
entry.count++;
|
||||||
return entry.count > publicRateLimit();
|
return entry.count > publicRateLimit(pluginLimit);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Test seam. */
|
/** Test seam. */
|
||||||
@@ -319,7 +327,9 @@ export function createPluginRouter({
|
|||||||
outer.use((req: Request, res: Response, next: NextFunction) => {
|
outer.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
if (isPublicRequest(req, publicPaths, inner)) {
|
if (isPublicRequest(req, publicPaths, inner)) {
|
||||||
const ip = req.ip || req.socket?.remoteAddress || "unknown";
|
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" });
|
res.status(429).json({ error: "Too many requests" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,6 +461,19 @@ describe("public route rate limit", () => {
|
|||||||
expect(http.overPublicRateLimit("other", "1.2.3.4", now)).toBe(false);
|
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", () => {
|
it("starts a new window after a minute", () => {
|
||||||
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
|
process.env.TERMIX_PLUGIN_PUBLIC_RATE_LIMIT = "1";
|
||||||
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
|
expect(http.overPublicRateLimit("demo", "ip", 0)).toBe(false);
|
||||||
|
|||||||
Reference in new issue
Block a user