auth-middleware: exempt plugin-defined webhook routes from daemon token

External services (Grafana, Sentry, Slack) call plugin webhooks with
their own per-plugin shared secret — they cannot present the
dashboard's daemon token. Daemon auth was 401'ing those callbacks
before they reached the plugin handler, so even with the route
correctly mounted the secret check inside the plugin never fired.

Add a registry of dynamically-exempt paths populated at server
startup when plugin routes are mounted. Plugin management routes
(/api/plugins, /api/plugins/:id/enable, etc.) stay gated; only the
plugin-defined routes (/api/plugins/:pluginId/<route>) are exempted.
Each plugin handler is responsible for its own secret check (the
telemetry-watcher webhook compares Authorization Bearer against
settings.grafanaWebhookSecret in constant time at the handler).
This commit is contained in:
Semih
2026-05-10 09:04:16 +00:00
parent 2d43ac7bd2
commit ddce7ff5a8
2 changed files with 49 additions and 1 deletions

View File

@@ -19,6 +19,38 @@ export const TOKEN_QUERY_PARAM = "fn_token";
/** Paths that are exempt from authentication (liveness probes). */
const EXEMPT_PATHS = ["/api/health"];
/**
* Paths that bypass the daemon-token check. These get populated from
* plugin-defined routes — webhook handlers (Grafana, Sentry, Slack) need
* to receive callbacks from external services that have no knowledge of
* the daemon token. Plugins authenticate the request with their own
* shared-secret bearer (see plugin handler implementations).
*
* Populated by registerPluginExemptPath() at startup, after the plugin
* loader has resolved its routes. Plugin management routes (/api/plugins,
* /api/plugins/:id/enable, etc.) stay gated — only the plugin-defined
* routes (/api/plugins/:pluginId/<route>) are exempted.
*/
const dynamicExemptExactPaths = new Set<string>();
const dynamicExemptPrefixPaths = new Set<string>();
/**
* Register a plugin-defined route path as exempt from daemon auth.
*
* @param fullPath - The mounted Express path (e.g. "/api/plugins/foo/webhook").
* Trailing slash and query string are normalized away.
*/
export function registerPluginExemptPath(fullPath: string): void {
const cleaned = fullPath.split("?")[0]!.replace(/\/+$/, "") || "/";
dynamicExemptExactPaths.add(cleaned);
}
/** For tests. */
export function clearPluginExemptPaths(): void {
dynamicExemptExactPaths.clear();
dynamicExemptPrefixPaths.clear();
}
/**
* Only /api/* paths are gated by this middleware. The SPA shell (index.html,
* /assets/*, favicon, etc.) must load unauthenticated so the frontend JS can
@@ -66,7 +98,18 @@ export function getDaemonToken(options?: { daemon?: { token: string }; noAuth?:
* Check if a request path is exempt from authentication.
*/
function isExemptPath(path: string): boolean {
return EXEMPT_PATHS.some((exempt) => path === exempt || path.startsWith(exempt + "/"));
if (EXEMPT_PATHS.some((exempt) => path === exempt || path.startsWith(exempt + "/"))) {
return true;
}
if (dynamicExemptExactPaths.has(path)) {
return true;
}
for (const prefix of dynamicExemptPrefixPaths) {
if (path === prefix || path.startsWith(prefix + "/")) {
return true;
}
}
return false;
}
/**

View File

@@ -13,6 +13,7 @@ import * as nodeFs from "node:fs";
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core";
import { type Task, type PiExtensionEntry, type PiExtensionSettings, AutomationStore, RoutineStore, isWebhookTrigger, MemoryBackendError, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, discoverPiExtensions, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { registerPluginExemptPath } from "./auth-middleware.js";
import { verifyWebhookSignature } from "./github-webhooks.js";
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
@@ -3204,6 +3205,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const pluginRoutes = pluginLoader.getPluginRoutes();
for (const { pluginId, route } of pluginRoutes) {
const fullPath = `/plugins/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
// Daemon auth bypass — plugin handlers do their own per-secret check.
// External services (Grafana/Sentry/Slack) cannot know the daemon
// token; they authenticate via the plugin's own bearer/HMAC config.
registerPluginExemptPath(`/api${fullPath}`);
const handler = async (req: Request, res: Response) => {
try {
let projectScopedTaskStore: import("@fusion/core").TaskStore | undefined;