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:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user