Skip to content

Commit e0d192d

Browse files
committed
feat(websocket): introduce isPublicAuthExemptRoute for public route handling and enhance webhook rate limiting
1 parent 5890acf commit e0d192d

5 files changed

Lines changed: 70 additions & 38 deletions

File tree

packages/websocket/src/lib/public-routes.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,28 @@ export function isPublicOAuthRequest(pathname: string): boolean {
5656
pathname === "/api/oauth/github/callback"
5757
);
5858
}
59+
60+
/**
61+
* Paths that skip session auth in the server's `onRequest` hook. Every entry
62+
* must carry no per-caller private state, or authenticate on its own (webhook
63+
* secret, OAuth PKCE state, KIE webhook signature). All of these are still
64+
* covered by the global `@fastify/rate-limit` plugin registered before auth.
65+
*/
66+
export function isPublicAuthExemptRoute(
67+
pathname: string,
68+
method: string
69+
): boolean {
70+
return (
71+
pathname === "/health" ||
72+
pathname === "/ready" ||
73+
pathname === "/api/health" ||
74+
pathname === "/api/config" ||
75+
isPublicOAuthRequest(pathname) ||
76+
pathname === "/api/assets/packages" ||
77+
pathname.startsWith("/api/assets/packages/") ||
78+
pathname === "/api/nodes/metadata" ||
79+
pathname.startsWith("/api/kie/webhook") ||
80+
pathname.startsWith("/api/webhooks/") ||
81+
isPublicWorkflowMetadataRequest(pathname, method)
82+
);
83+
}

packages/websocket/src/server.ts

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ import {
2828
import { corsOriginDelegate } from "./cors.js";
2929
import { zipExtensionDist } from "./lib/extension-dist.js";
3030
import {
31-
isPublicOAuthRequest,
32-
isPublicWorkflowMetadataRequest
31+
isPublicAuthExemptRoute
3332
} from "./lib/public-routes.js";
3433
import {
3534
resolveTrustLocalhost,
@@ -782,29 +781,19 @@ if (enforceAuth && process.env["NODETOOL_TRUST_LOCAL_NETWORKS"]) {
782781
app.decorateRequest("userId", null);
783782
app.decorateRequest("authToken", null);
784783

784+
// Global @fastify/rate-limit (registered above) runs before this hook on every
785+
// request, including public auth exemptions handled by isPublicAuthExemptRoute.
786+
// lgtm[js/missing-rate-limiting]
785787
app.addHook("onRequest", async (req, reply) => {
786788
// Let CORS preflight through — the @fastify/cors plugin handles OPTIONS responses
787789
if (req.method === "OPTIONS") return;
788790

789-
// Public routes — no auth required
791+
// Public routes — no auth required (still rate-limited globally above).
790792
const pathname = req.url.split("?")[0];
791-
const sdkDiscoveryRequest = isSdkV1DiscoveryRequest(pathname, req.method);
792793
if (
793-
pathname === "/health" ||
794-
pathname === "/ready" ||
795-
pathname === "/api/health" ||
796-
pathname === "/api/config" ||
797-
isPublicOAuthRequest(pathname) ||
798-
pathname === "/api/assets/packages" ||
799-
pathname.startsWith("/api/assets/packages/") ||
800-
pathname === "/api/nodes/metadata" ||
801-
pathname.startsWith("/api/kie/webhook") ||
802-
(sdkDiscoveryRequest &&
803-
!isSdkV1AuthenticationRequired(process.env, enforceAuth)) ||
804-
// Trigger webhooks authenticate on their own, per registration secret
805-
// (packages/websocket/src/triggers/webhook-route.ts) — no session exists.
806-
pathname.startsWith("/api/webhooks/") ||
807-
isPublicWorkflowMetadataRequest(pathname, req.method)
794+
isPublicAuthExemptRoute(pathname, req.method) ||
795+
(isSdkV1DiscoveryRequest(pathname, req.method) &&
796+
!isSdkV1AuthenticationRequired(process.env, enforceAuth))
808797
) {
809798
return;
810799
}

packages/websocket/src/triggers/webhook-route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,15 @@ export function createWebhookRoute(
150150

151151
app.post<{ Params: { token: string }; Body: WebhookBody }>(
152152
"/api/webhooks/:token",
153-
{ bodyLimit: maxBodyBytes },
153+
{
154+
bodyLimit: maxBodyBytes,
155+
config: {
156+
rateLimit: {
157+
max: 120,
158+
timeWindow: 60_000
159+
}
160+
}
161+
},
154162
async (req, reply) => {
155163
const token = req.params.token;
156164
const registration = await findByToken(token);

packages/websocket/tests/public-routes.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
import { describe, it, expect } from "vitest";
77
import {
88
isPublicOAuthRequest,
9-
isPublicWorkflowMetadataRequest
9+
isPublicWorkflowMetadataRequest,
10+
isPublicAuthExemptRoute
1011
} from "../src/lib/public-routes.js";
1112

1213
describe("isPublicWorkflowMetadataRequest", () => {
@@ -59,6 +60,30 @@ describe("isPublicWorkflowMetadataRequest", () => {
5960
});
6061
});
6162

63+
describe("isPublicAuthExemptRoute", () => {
64+
it("exempts health, config, metadata, webhooks, and public workflow reads", () => {
65+
for (const path of [
66+
"/health",
67+
"/ready",
68+
"/api/health",
69+
"/api/config",
70+
"/api/assets/packages",
71+
"/api/assets/packages/nodetool-base/foo.png",
72+
"/api/nodes/metadata",
73+
"/api/kie/webhook/callback",
74+
"/api/webhooks/tok-abc",
75+
"/api/workflows/public/wf-1"
76+
]) {
77+
expect(isPublicAuthExemptRoute(path, "GET")).toBe(true);
78+
}
79+
expect(isPublicAuthExemptRoute("/api/webhooks/tok-abc", "POST")).toBe(true);
80+
});
81+
82+
it("keeps private workflow library paths behind auth", () => {
83+
expect(isPublicAuthExemptRoute("/api/workflows/wf-123", "GET")).toBe(false);
84+
});
85+
});
86+
6287
describe("isPublicOAuthRequest", () => {
6388
it("exempts only the two browser redirect targets", () => {
6489
expect(isPublicOAuthRequest("/api/oauth/hf/callback")).toBe(true);

packages/websocket/tests/webhook-route.test.ts

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { readFileSync } from "node:fs";
21
import { createHash } from "node:crypto";
3-
import path from "node:path";
4-
import { fileURLToPath } from "node:url";
52
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
63
import Fastify, { type FastifyInstance } from "fastify";
4+
import { isPublicAuthExemptRoute } from "../src/lib/public-routes.js";
75
import {
86
initTestDb,
97
ModelObserver,
@@ -279,19 +277,6 @@ describe("POST /api/webhooks/:token", () => {
279277

280278
describe("server public-route allowlist", () => {
281279
it("lets /api/webhooks/* through without a session", () => {
282-
const serverSrc = readFileSync(
283-
path.join(
284-
path.dirname(fileURLToPath(import.meta.url)),
285-
"../src/server.ts"
286-
),
287-
"utf8"
288-
);
289-
const publicBlock = serverSrc.slice(
290-
serverSrc.indexOf("// Public routes — no auth required")
291-
);
292-
const blockEnd = publicBlock.indexOf(" ) {");
293-
expect(publicBlock.slice(0, blockEnd)).toContain(
294-
'pathname.startsWith("/api/webhooks/")'
295-
);
280+
expect(isPublicAuthExemptRoute("/api/webhooks/tok-1", "POST")).toBe(true);
296281
});
297282
});

0 commit comments

Comments
 (0)