@xopcai/xopc 0.0.27 → 0.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extensions/telegram/xopc.extension.json +1 -1
- package/dist/extensions/weixin/src/adapters/onboard-cli.d.ts +7 -0
- package/dist/extensions/weixin/src/adapters/onboard-cli.js +61 -0
- package/dist/extensions/weixin/src/adapters/onboard-cli.js.map +1 -0
- package/dist/extensions/weixin/src/cli/qr-login.d.ts +5 -0
- package/dist/extensions/weixin/src/cli/qr-login.js +1 -1
- package/dist/extensions/weixin/src/cli/qr-login.js.map +1 -1
- package/dist/extensions/weixin/src/index.js +1 -1
- package/dist/extensions/weixin/src/plugin.d.ts +1 -0
- package/dist/extensions/weixin/src/plugin.js +2 -0
- package/dist/extensions/weixin/src/plugin.js.map +1 -1
- package/dist/gateway/static/root/assets/{agents-w8_jzuiX.js → agents-DplaQYS2.js} +2 -2
- package/dist/gateway/static/root/assets/{agents-w8_jzuiX.js.map → agents-DplaQYS2.js.map} +1 -1
- package/dist/gateway/static/root/assets/{apps-page-CBBh_Ww8.js → apps-page-Co95hLOJ.js} +2 -2
- package/dist/gateway/static/root/assets/{apps-page-CBBh_Ww8.js.map → apps-page-Co95hLOJ.js.map} +1 -1
- package/dist/gateway/static/root/assets/{channels-settings-DUKRPC7C.js → channels-settings-CkfSST0k.js} +2 -2
- package/dist/gateway/static/root/assets/{channels-settings-DUKRPC7C.js.map → channels-settings-CkfSST0k.js.map} +1 -1
- package/dist/gateway/static/root/assets/{cron-page-S18t1yG-.js → cron-page-D9q6KqL8.js} +2 -2
- package/dist/gateway/static/root/assets/{cron-page-S18t1yG-.js.map → cron-page-D9q6KqL8.js.map} +1 -1
- package/dist/gateway/static/root/assets/{cron-utils-08gdQfl9.js → cron-utils-BmzF4m1y.js} +2 -2
- package/dist/gateway/static/root/assets/{cron-utils-08gdQfl9.js.map → cron-utils-BmzF4m1y.js.map} +1 -1
- package/dist/gateway/static/root/assets/{dist-C1MrygQH.js → dist-Dn-ufXyc.js} +2 -2
- package/dist/gateway/static/root/assets/{dist-C1MrygQH.js.map → dist-Dn-ufXyc.js.map} +1 -1
- package/dist/gateway/static/root/assets/{extension-debug-page-DN3HKUGS.js → extension-debug-page-BZ8xQ74_.js} +2 -2
- package/dist/gateway/static/root/assets/{extension-debug-page-DN3HKUGS.js.map → extension-debug-page-BZ8xQ74_.js.map} +1 -1
- package/dist/gateway/static/root/assets/{extension-page-CoFDHZtZ.js → extension-page-BlNgKxwW.js} +2 -2
- package/dist/gateway/static/root/assets/{extension-page-CoFDHZtZ.js.map → extension-page-BlNgKxwW.js.map} +1 -1
- package/dist/gateway/static/root/assets/{extension-settings-page-BcPCu_Go.js → extension-settings-page-CWTdW_oY.js} +2 -2
- package/dist/gateway/static/root/assets/{extension-settings-page-BcPCu_Go.js.map → extension-settings-page-CWTdW_oY.js.map} +1 -1
- package/dist/gateway/static/root/assets/{index-PfkB8N37.js → index-lV8FGWlt.js} +4 -4
- package/dist/gateway/static/root/assets/{index-PfkB8N37.js.map → index-lV8FGWlt.js.map} +1 -1
- package/dist/gateway/static/root/assets/logs-page-DG31RpvG.js +2 -0
- package/dist/gateway/static/root/assets/logs-page-DG31RpvG.js.map +1 -0
- package/dist/gateway/static/root/assets/sessions-page-CdmjxDEM.js +2 -0
- package/dist/gateway/static/root/assets/{sessions-page-2uOYwEwd.js.map → sessions-page-CdmjxDEM.js.map} +1 -1
- package/dist/gateway/static/root/assets/{settings-page-fQWswCuq.js → settings-page-DU2XLf5s.js} +2 -2
- package/dist/gateway/static/root/assets/{settings-page-fQWswCuq.js.map → settings-page-DU2XLf5s.js.map} +1 -1
- package/dist/gateway/static/root/assets/{skills-page-BmBDCEbY.js → skills-page-lb7vYtlP.js} +2 -2
- package/dist/gateway/static/root/assets/{skills-page-BmBDCEbY.js.map → skills-page-lb7vYtlP.js.map} +1 -1
- package/dist/gateway/static/root/index.html +1 -1
- package/dist/package.js +1 -1
- package/dist/src/channels/index.js +2 -2
- package/dist/src/channels/manager.js +2 -2
- package/dist/src/channels/weixin/index.js +1 -1
- package/dist/src/cli/agent-chat-log-level-preset.d.ts +7 -0
- package/dist/src/cli/agent-chat-log-level-preset.js +22 -0
- package/dist/src/cli/agent-chat-log-level-preset.js.map +1 -0
- package/dist/src/cli/commands/agent/interactive.js +4 -2
- package/dist/src/cli/commands/agent/interactive.js.map +1 -1
- package/dist/src/cli/commands/agent/stream-renderer.d.ts +14 -0
- package/dist/src/cli/commands/agent/stream-renderer.js +99 -0
- package/dist/src/cli/commands/agent/stream-renderer.js.map +1 -0
- package/dist/src/cli/commands/agent.js +2 -2
- package/dist/src/cli/commands/agent.js.map +1 -1
- package/dist/src/cli/commands/onboard.js +77 -93
- package/dist/src/cli/commands/onboard.js.map +1 -1
- package/dist/src/cli/commands/tui.d.ts +1 -0
- package/dist/src/cli/commands/tui.js +40 -0
- package/dist/src/cli/commands/tui.js.map +1 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +3 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/config/schema.d.ts +6 -0
- package/dist/src/config/schema.js +6 -1
- package/dist/src/config/schema.js.map +1 -1
- package/dist/src/gateway/auth.d.ts +17 -3
- package/dist/src/gateway/auth.js +35 -16
- package/dist/src/gateway/auth.js.map +1 -1
- package/dist/src/gateway/hono/app.js +30 -1
- package/dist/src/gateway/hono/app.js.map +1 -1
- package/dist/src/gateway/hono/lib/config-payload.d.ts +1 -1
- package/dist/src/gateway/hono/middleware/auth.js +4 -3
- package/dist/src/gateway/hono/middleware/auth.js.map +1 -1
- package/dist/src/gateway/hono/middleware/scopes.d.ts +15 -0
- package/dist/src/gateway/hono/middleware/scopes.js +41 -0
- package/dist/src/gateway/hono/middleware/scopes.js.map +1 -0
- package/dist/src/gateway/security/audit.d.ts +18 -0
- package/dist/src/gateway/security/audit.js +68 -0
- package/dist/src/gateway/security/audit.js.map +1 -0
- package/dist/src/gateway/security/csp.d.ts +19 -0
- package/dist/src/gateway/security/csp.js +52 -0
- package/dist/src/gateway/security/csp.js.map +1 -0
- package/dist/src/gateway/security/dangerous-tools.d.ts +20 -0
- package/dist/src/gateway/security/dangerous-tools.js +46 -0
- package/dist/src/gateway/security/dangerous-tools.js.map +1 -0
- package/dist/src/gateway/security/flood-guard.d.ts +28 -0
- package/dist/src/gateway/security/flood-guard.js +42 -0
- package/dist/src/gateway/security/flood-guard.js.map +1 -0
- package/dist/src/gateway/security/index.d.ts +9 -0
- package/dist/src/gateway/security/index.js +10 -0
- package/dist/src/gateway/security/known-weak-secrets.d.ts +10 -0
- package/dist/src/gateway/security/known-weak-secrets.js +36 -0
- package/dist/src/gateway/security/known-weak-secrets.js.map +1 -0
- package/dist/src/gateway/security/operator-scopes.d.ts +37 -0
- package/dist/src/gateway/security/operator-scopes.js +137 -0
- package/dist/src/gateway/security/operator-scopes.js.map +1 -0
- package/dist/src/gateway/security/origin-check.d.ts +21 -0
- package/dist/src/gateway/security/origin-check.js +56 -0
- package/dist/src/gateway/security/origin-check.js.map +1 -0
- package/dist/src/gateway/security/preauth-connection-budget.d.ts +17 -0
- package/dist/src/gateway/security/preauth-connection-budget.js +49 -0
- package/dist/src/gateway/security/preauth-connection-budget.js.map +1 -0
- package/dist/src/gateway/security/secret-equal.d.ts +8 -0
- package/dist/src/gateway/security/secret-equal.js +30 -0
- package/dist/src/gateway/security/secret-equal.js.map +1 -0
- package/dist/src/gateway/service.d.ts +1 -1
- package/dist/src/gateway/service.js +11 -2
- package/dist/src/gateway/service.js.map +1 -1
- package/dist/src/tui/backends/embedded-backend.d.ts +42 -0
- package/dist/src/tui/backends/embedded-backend.js +160 -0
- package/dist/src/tui/backends/embedded-backend.js.map +1 -0
- package/dist/src/tui/backends/gateway-sse-backend.d.ts +49 -0
- package/dist/src/tui/backends/gateway-sse-backend.js +226 -0
- package/dist/src/tui/backends/gateway-sse-backend.js.map +1 -0
- package/dist/src/tui/components/assistant-message.d.ts +6 -0
- package/dist/src/tui/components/assistant-message.js +19 -0
- package/dist/src/tui/components/assistant-message.js.map +1 -0
- package/dist/src/tui/components/chat-log.d.ts +19 -0
- package/dist/src/tui/components/chat-log.js +99 -0
- package/dist/src/tui/components/chat-log.js.map +1 -0
- package/dist/src/tui/components/custom-editor.d.ts +13 -0
- package/dist/src/tui/components/custom-editor.js +44 -0
- package/dist/src/tui/components/custom-editor.js.map +1 -0
- package/dist/src/tui/components/tool-execution.d.ts +16 -0
- package/dist/src/tui/components/tool-execution.js +76 -0
- package/dist/src/tui/components/tool-execution.js.map +1 -0
- package/dist/src/tui/components/user-message.d.ts +6 -0
- package/dist/src/tui/components/user-message.js +22 -0
- package/dist/src/tui/components/user-message.js.map +1 -0
- package/dist/src/tui/sse-consumer.d.ts +15 -0
- package/dist/src/tui/sse-consumer.js +75 -0
- package/dist/src/tui/sse-consumer.js.map +1 -0
- package/dist/src/tui/stream-assembler.d.ts +22 -0
- package/dist/src/tui/stream-assembler.js +63 -0
- package/dist/src/tui/stream-assembler.js.map +1 -0
- package/dist/src/tui/theme.d.ts +71 -0
- package/dist/src/tui/theme.js +151 -0
- package/dist/src/tui/theme.js.map +1 -0
- package/dist/src/tui/tui-backend.d.ts +84 -0
- package/dist/src/tui/tui-backend.js +1 -0
- package/dist/src/tui/tui-types.d.ts +85 -0
- package/dist/src/tui/tui-types.js +21 -0
- package/dist/src/tui/tui-types.js.map +1 -0
- package/dist/src/tui/tui.d.ts +3 -0
- package/dist/src/tui/tui.js +526 -0
- package/dist/src/tui/tui.js.map +1 -0
- package/package.json +9 -3
- package/dist/gateway/static/root/assets/logs-page-DoWe1GWy.js +0 -2
- package/dist/gateway/static/root/assets/logs-page-DoWe1GWy.js.map +0 -1
- package/dist/gateway/static/root/assets/sessions-page-2uOYwEwd.js +0 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","names":[],"sources":["../../../src/gateway/auth.ts"],"sourcesContent":["import crypto from 'crypto';\nimport type { GatewayAuthConfig } from '../config/schema.js';\n\n/**\n * Resolved gateway authentication configuration.\n */\nexport interface ResolvedGatewayAuth {\n mode: 'none' | 'token';\n token?: string;\n}\n\n/**\n * Resolve gateway authentication configuration.\n * Priority: env vars > config > defaults\n */\nexport function resolveGatewayAuth(params: {\n authConfig?: GatewayAuthConfig | null;\n env?: NodeJS.ProcessEnv;\n}): ResolvedGatewayAuth {\n const env = params.env ?? process.env;\n const config: GatewayAuthConfig = params.authConfig ?? { mode: 'token' };\n\n // Environment variables take precedence\n const envMode = env.XOPC_GATEWAY_AUTH_MODE;\n const envToken = env.XOPC_GATEWAY_TOKEN;\n\n // Resolve mode\n let mode: ResolvedGatewayAuth['mode'] = 'token';\n if (envMode === 'none' || envMode === 'token') {\n mode = envMode;\n } else if (config.mode === 'none') {\n mode = '
|
|
1
|
+
{"version":3,"file":"auth.js","names":[],"sources":["../../../src/gateway/auth.ts"],"sourcesContent":["import crypto from 'crypto';\nimport type { GatewayAuthConfig } from '../config/schema.js';\nimport { safeEqualSecret } from './security/secret-equal.js';\n\n/**\n * Resolved gateway authentication configuration.\n *\n * Supports three modes:\n * - `none`: no authentication (local dev only)\n * - `token`: Bearer token authentication (default)\n * - `password`: password-based authentication (for simpler setups)\n */\nexport interface ResolvedGatewayAuth {\n mode: 'none' | 'token' | 'password';\n token?: string;\n password?: string;\n}\n\n/**\n * Resolve gateway authentication configuration.\n * Priority: env vars > config > defaults\n */\nexport function resolveGatewayAuth(params: {\n authConfig?: GatewayAuthConfig | null;\n env?: NodeJS.ProcessEnv;\n}): ResolvedGatewayAuth {\n const env = params.env ?? process.env;\n const config: GatewayAuthConfig = params.authConfig ?? { mode: 'token' };\n\n // Environment variables take precedence\n const envMode = env.XOPC_GATEWAY_AUTH_MODE;\n const envToken = env.XOPC_GATEWAY_TOKEN;\n const envPassword = env.XOPC_GATEWAY_PASSWORD;\n\n // Resolve mode\n let mode: ResolvedGatewayAuth['mode'] = 'token';\n if (envMode === 'none' || envMode === 'token' || envMode === 'password') {\n mode = envMode;\n } else if (config.mode === 'none' || config.mode === 'password') {\n mode = config.mode;\n }\n\n // Ambiguity detection: reject conflicting credential types\n const hasToken = Boolean(envToken || config.token);\n const hasPassword = Boolean(envPassword || config.password);\n if (hasToken && hasPassword) {\n throw new Error(\n 'Invalid config: both gateway.auth.token and gateway.auth.password are set. ' +\n 'Choose one authentication mode: \"token\" (Bearer header) or \"password\".',\n );\n }\n\n // Resolve token\n let token: string | undefined;\n if (mode === 'token') {\n if (envToken) {\n token = envToken;\n } else if (config.token) {\n token = config.token;\n } else {\n // Auto-generate token if not provided\n token = crypto.randomBytes(24).toString('hex');\n }\n }\n\n // Resolve password\n let password: string | undefined;\n if (mode === 'password') {\n if (envPassword) {\n password = envPassword;\n } else if (config.password) {\n password = config.password;\n }\n }\n\n return { mode, token, password };\n}\n\n/**\n * Assert that gateway auth is properly configured.\n */\nexport function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {\n if (auth.mode === 'token' && !auth.token) {\n throw new Error(\n 'Gateway auth mode is token, but no token was configured. ' +\n 'Set gateway.auth.token in config or XOPC_GATEWAY_TOKEN environment variable.',\n );\n }\n if (auth.mode === 'password' && !auth.password) {\n throw new Error(\n 'Gateway auth mode is password, but no password was configured. ' +\n 'Set gateway.auth.password in config or XOPC_GATEWAY_PASSWORD environment variable.',\n );\n }\n}\n\n/**\n * Constant-time string comparison to prevent timing attacks.\n *\n * Uses `crypto.timingSafeEqual` with padding so both buffers always have\n * the same byte length. The actual length is checked separately.\n *\n * @deprecated Use `safeEqualSecret` from `./security/secret-equal.js` directly.\n */\nexport function safeCompare(a: string, b: string): boolean {\n return safeEqualSecret(a, b);\n}\n\n/**\n * Validate a credential against configured auth using constant-time comparison.\n *\n * Works for both token and password modes — the caller extracts the credential\n * from the appropriate transport (header, query param, etc.).\n */\nexport function validateToken(auth: ResolvedGatewayAuth, providedCredential?: string | null): boolean {\n if (auth.mode === 'none') {\n return true;\n }\n\n if (!providedCredential) {\n return false;\n }\n\n if (auth.mode === 'password') {\n if (!auth.password) return false;\n return safeEqualSecret(auth.password, providedCredential);\n }\n\n // Default: token mode\n if (!auth.token) return false;\n return safeEqualSecret(auth.token, providedCredential);\n}\n\n/**\n * Extract token from request headers.\n * Supports: Authorization: Bearer <token>, X-Api-Key: <token>\n */\nexport function extractToken(headers?: Record<string, string | string[] | undefined>): string | undefined {\n if (!headers) return undefined;\n\n // Authorization: Bearer <token>\n const authHeader = headers.authorization;\n if (authHeader) {\n const value = Array.isArray(authHeader) ? authHeader[0] : authHeader;\n if (value?.startsWith('Bearer ')) {\n return value.slice(7);\n }\n }\n\n // X-Api-Key: <token>\n const apiKey = headers['x-api-key'];\n if (apiKey) {\n return Array.isArray(apiKey) ? apiKey[0] : apiKey;\n }\n\n return undefined;\n}\n"],"mappings":";;;;;;;AAsBA,SAAgB,mBAAmB,QAGX;CACtB,MAAM,MAAM,OAAO,OAAO,QAAQ;CAClC,MAAM,SAA4B,OAAO,cAAc,EAAE,MAAM,SAAS;CAGxE,MAAM,UAAU,IAAI;CACpB,MAAM,WAAW,IAAI;CACrB,MAAM,cAAc,IAAI;CAGxB,IAAI,OAAoC;AACxC,KAAI,YAAY,UAAU,YAAY,WAAW,YAAY,WAC3D,QAAO;UACE,OAAO,SAAS,UAAU,OAAO,SAAS,WACnD,QAAO,OAAO;CAIhB,MAAM,WAAW,QAAQ,YAAY,OAAO,MAAM;CAClD,MAAM,cAAc,QAAQ,eAAe,OAAO,SAAS;AAC3D,KAAI,YAAY,YACd,OAAM,IAAI,MACR,wJAED;CAIH,IAAI;AACJ,KAAI,SAAS,QACX,KAAI,SACF,SAAQ;UACC,OAAO,MAChB,SAAQ,OAAO;KAGf,SAAQ,OAAO,YAAY,GAAG,CAAC,SAAS,MAAM;CAKlD,IAAI;AACJ,KAAI,SAAS;MACP,YACF,YAAW;WACF,OAAO,SAChB,YAAW,OAAO;;AAItB,QAAO;EAAE;EAAM;EAAO;EAAU;;;;;AAMlC,SAAgB,4BAA4B,MAAiC;AAC3E,KAAI,KAAK,SAAS,WAAW,CAAC,KAAK,MACjC,OAAM,IAAI,MACR,wIAED;AAEH,KAAI,KAAK,SAAS,cAAc,CAAC,KAAK,SACpC,OAAM,IAAI,MACR,oJAED;;;;;;;;;;AAYL,SAAgB,YAAY,GAAW,GAAoB;AACzD,QAAO,gBAAgB,GAAG,EAAE;;;;;;;;AAS9B,SAAgB,cAAc,MAA2B,oBAA6C;AACpG,KAAI,KAAK,SAAS,OAChB,QAAO;AAGT,KAAI,CAAC,mBACH,QAAO;AAGT,KAAI,KAAK,SAAS,YAAY;AAC5B,MAAI,CAAC,KAAK,SAAU,QAAO;AAC3B,SAAO,gBAAgB,KAAK,UAAU,mBAAmB;;AAI3D,KAAI,CAAC,KAAK,MAAO,QAAO;AACxB,QAAO,gBAAgB,KAAK,OAAO,mBAAmB;;;;;;AAOxD,SAAgB,aAAa,SAA6E;AACxG,KAAI,CAAC,QAAS,QAAO,KAAA;CAGrB,MAAM,aAAa,QAAQ;AAC3B,KAAI,YAAY;EACd,MAAM,QAAQ,MAAM,QAAQ,WAAW,GAAG,WAAW,KAAK;AAC1D,MAAI,OAAO,WAAW,UAAU,CAC9B,QAAO,MAAM,MAAM,EAAE;;CAKzB,MAAM,SAAS,QAAQ;AACvB,KAAI,OACF,QAAO,MAAM,QAAQ,OAAO,GAAG,OAAO,KAAK"}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { createLogger } from "../../utils/logger/index.js";
|
|
2
2
|
import { init_logger } from "../../utils/logger.js";
|
|
3
3
|
import { maxWebchatAgentRequestBodyBytes } from "../chat-limits.js";
|
|
4
|
+
import { buildGatewayConsoleCspHeader } from "../security/csp.js";
|
|
5
|
+
import { checkBrowserOrigin } from "../security/origin-check.js";
|
|
4
6
|
import { auth } from "./middleware/auth.js";
|
|
7
|
+
import { operatorScopes } from "./middleware/scopes.js";
|
|
5
8
|
import { logContextMiddleware } from "./middleware/log-context.js";
|
|
6
9
|
import { logger } from "./middleware/logger.js";
|
|
7
10
|
import { registerPublicExtensionAssetRoutes } from "./routes/auth-registry-extensions.js";
|
|
@@ -57,6 +60,7 @@ function createHonoApp(config) {
|
|
|
57
60
|
app.use(logContextMiddleware());
|
|
58
61
|
app.use(logger());
|
|
59
62
|
app.use(cors(CORS_OPTIONS));
|
|
63
|
+
const gatewayConsoleCsp = buildGatewayConsoleCspHeader();
|
|
60
64
|
app.use(createMiddleware(async (c, next) => {
|
|
61
65
|
await next();
|
|
62
66
|
if (isExtensionGatewayUiAssetPath(c.req.path)) return;
|
|
@@ -65,7 +69,31 @@ function createHonoApp(config) {
|
|
|
65
69
|
c.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
66
70
|
c.header("X-XSS-Protection", "1; mode=block");
|
|
67
71
|
c.header("Permissions-Policy", "camera=(), microphone=(self), geolocation=()");
|
|
68
|
-
c.header("Content-Security-Policy",
|
|
72
|
+
c.header("Content-Security-Policy", gatewayConsoleCsp);
|
|
73
|
+
}));
|
|
74
|
+
const allowedOrigins = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];
|
|
75
|
+
app.use("/api/*", createMiddleware(async (c, next) => {
|
|
76
|
+
const origin = c.req.header("origin");
|
|
77
|
+
if (!origin) return next();
|
|
78
|
+
const result = checkBrowserOrigin({
|
|
79
|
+
requestHost: c.req.header("host"),
|
|
80
|
+
origin,
|
|
81
|
+
allowedOrigins,
|
|
82
|
+
allowHostHeaderOriginFallback: true,
|
|
83
|
+
isLocalClient: false
|
|
84
|
+
});
|
|
85
|
+
if (!result.ok) {
|
|
86
|
+
log.warn({
|
|
87
|
+
origin,
|
|
88
|
+
reason: "reason" in result ? result.reason : "unknown",
|
|
89
|
+
path: c.req.path
|
|
90
|
+
}, "Browser origin check failed");
|
|
91
|
+
return c.json({
|
|
92
|
+
error: "Forbidden",
|
|
93
|
+
message: "Origin not allowed"
|
|
94
|
+
}, 403);
|
|
95
|
+
}
|
|
96
|
+
return next();
|
|
69
97
|
}));
|
|
70
98
|
app.use("/api/skills/upload", bodyLimit({
|
|
71
99
|
maxSize: 10 * 1024 * 1024,
|
|
@@ -96,6 +124,7 @@ function createHonoApp(config) {
|
|
|
96
124
|
token,
|
|
97
125
|
getGatewayAuth: () => service.currentConfig.gateway?.auth
|
|
98
126
|
}));
|
|
127
|
+
authenticated.use(operatorScopes());
|
|
99
128
|
const strictRateLimiter = /* @__PURE__ */ new Map();
|
|
100
129
|
setInterval(() => {
|
|
101
130
|
for (const [ip, limiter] of strictRateLimiter.entries()) if (limiter.consume().remaining === 9) strictRateLimiter.delete(ip);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.js","names":[],"sources":["../../../../src/gateway/hono/app.ts"],"sourcesContent":["import { Hono } from 'hono';\nimport { cors } from 'hono/cors';\nimport { createMiddleware } from 'hono/factory';\nimport { bodyLimit } from 'hono/body-limit';\n\nimport { createFixedWindowRateLimiter } from '../../infra/rate-limit.js';\nimport { createLogger } from '../../utils/logger.js';\nimport type { GatewayService } from '../service.js';\nimport { maxWebchatAgentRequestBodyBytes } from '../chat-limits.js';\nimport { auth } from './middleware/auth.js';\nimport { logContextMiddleware } from './middleware/log-context.js';\nimport { logger } from './middleware/logger.js';\nimport { registerPublicExtensionAssetRoutes } from './routes/auth-registry-extensions.js';\nimport { registerAuthenticatedRoutes } from './routes/index.js';\nimport { registerPublicGatewayRoutes } from './routes/public-gateway.js';\n\nconst log = createLogger('HonoApp');\n\nexport interface HonoAppConfig {\n service: GatewayService;\n token?: string;\n}\n\n/**\n * Extension sandbox HTML under `/api/extensions/:id/assets/*` ships its own CSP\n * (`frame-ancestors 'self'`). The global gateway middleware must not overwrite it\n * with `frame-ancestors 'none'` / `X-Frame-Options: DENY`, or the console cannot embed iframes.\n */\nexport function isExtensionGatewayUiAssetPath(path: string): boolean {\n return /^\\/api\\/extensions\\/[^/]+\\/assets\\//.test(path);\n}\n\nexport function createHonoApp(config: HonoAppConfig): Hono {\n const { service, token } = config;\n const app = new Hono();\n\n const gatewayPort = service.currentConfig.gateway.port ?? 18790;\n const configuredOrigins = service.currentConfig.gateway.corsOrigins;\n\n let corsOrigin: string | string[];\n if (configuredOrigins && configuredOrigins.length > 0) {\n corsOrigin = configuredOrigins;\n } else {\n corsOrigin = [\n `http://localhost:${gatewayPort}`,\n `http://127.0.0.1:${gatewayPort}`,\n 'http://localhost:3000',\n 'http://127.0.0.1:3000',\n ];\n }\n\n const CORS_OPTIONS = {\n origin: corsOrigin,\n allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],\n allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Session-Id', 'Last-Event-ID'],\n credentials: true,\n maxAge: 86400,\n };\n\n app.use(logContextMiddleware());\n app.use(logger());\n app.use(cors(CORS_OPTIONS));\n\n app.use(createMiddleware(async (c, next) => {\n await next();\n if (isExtensionGatewayUiAssetPath(c.req.path)) {\n return;\n }\n c.header('X-Frame-Options', 'DENY');\n c.header('X-Content-Type-Options', 'nosniff');\n c.header('Referrer-Policy', 'strict-origin-when-cross-origin');\n c.header('X-XSS-Protection', '1; mode=block');\n // microphone=(self): allow same-origin chat voice (composer). microphone=() breaks packaged Electron loading the gateway SPA.\n c.header('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');\n c.header(
|
|
1
|
+
{"version":3,"file":"app.js","names":[],"sources":["../../../../src/gateway/hono/app.ts"],"sourcesContent":["import { Hono } from 'hono';\nimport { cors } from 'hono/cors';\nimport { createMiddleware } from 'hono/factory';\nimport { bodyLimit } from 'hono/body-limit';\n\nimport { createFixedWindowRateLimiter } from '../../infra/rate-limit.js';\nimport { createLogger } from '../../utils/logger.js';\nimport type { GatewayService } from '../service.js';\nimport { maxWebchatAgentRequestBodyBytes } from '../chat-limits.js';\nimport { buildGatewayConsoleCspHeader } from '../security/csp.js';\nimport { checkBrowserOrigin } from '../security/origin-check.js';\nimport { auth } from './middleware/auth.js';\nimport { operatorScopes } from './middleware/scopes.js';\nimport { logContextMiddleware } from './middleware/log-context.js';\nimport { logger } from './middleware/logger.js';\nimport { registerPublicExtensionAssetRoutes } from './routes/auth-registry-extensions.js';\nimport { registerAuthenticatedRoutes } from './routes/index.js';\nimport { registerPublicGatewayRoutes } from './routes/public-gateway.js';\n\nconst log = createLogger('HonoApp');\n\nexport interface HonoAppConfig {\n service: GatewayService;\n token?: string;\n}\n\n/**\n * Extension sandbox HTML under `/api/extensions/:id/assets/*` ships its own CSP\n * (`frame-ancestors 'self'`). The global gateway middleware must not overwrite it\n * with `frame-ancestors 'none'` / `X-Frame-Options: DENY`, or the console cannot embed iframes.\n */\nexport function isExtensionGatewayUiAssetPath(path: string): boolean {\n return /^\\/api\\/extensions\\/[^/]+\\/assets\\//.test(path);\n}\n\nexport function createHonoApp(config: HonoAppConfig): Hono {\n const { service, token } = config;\n const app = new Hono();\n\n const gatewayPort = service.currentConfig.gateway.port ?? 18790;\n const configuredOrigins = service.currentConfig.gateway.corsOrigins;\n\n let corsOrigin: string | string[];\n if (configuredOrigins && configuredOrigins.length > 0) {\n corsOrigin = configuredOrigins;\n } else {\n corsOrigin = [\n `http://localhost:${gatewayPort}`,\n `http://127.0.0.1:${gatewayPort}`,\n 'http://localhost:3000',\n 'http://127.0.0.1:3000',\n ];\n }\n\n const CORS_OPTIONS = {\n origin: corsOrigin,\n allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],\n allowHeaders: ['Content-Type', 'Authorization', 'Accept', 'X-Session-Id', 'Last-Event-ID'],\n credentials: true,\n maxAge: 86400,\n };\n\n app.use(logContextMiddleware());\n app.use(logger());\n app.use(cors(CORS_OPTIONS));\n\n // Build CSP header once at startup (no inline script hashes needed for SPA)\n const gatewayConsoleCsp = buildGatewayConsoleCspHeader();\n\n // Security headers middleware\n app.use(createMiddleware(async (c, next) => {\n await next();\n if (isExtensionGatewayUiAssetPath(c.req.path)) {\n return;\n }\n c.header('X-Frame-Options', 'DENY');\n c.header('X-Content-Type-Options', 'nosniff');\n c.header('Referrer-Policy', 'strict-origin-when-cross-origin');\n c.header('X-XSS-Protection', '1; mode=block');\n // microphone=(self): allow same-origin chat voice (composer). microphone=() breaks packaged Electron loading the gateway SPA.\n c.header('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');\n c.header('Content-Security-Policy', gatewayConsoleCsp);\n }));\n\n // Browser Origin check middleware for API routes (CSRF protection).\n // Non-browser requests (no Origin header) pass through — they are\n // authenticated by the token middleware instead.\n const allowedOrigins = Array.isArray(corsOrigin) ? corsOrigin : [corsOrigin];\n app.use('/api/*', createMiddleware(async (c, next) => {\n const origin = c.req.header('origin');\n if (!origin) {\n // Non-browser request (CLI, server-to-server) — skip origin check\n return next();\n }\n\n const result = checkBrowserOrigin({\n requestHost: c.req.header('host'),\n origin,\n allowedOrigins,\n allowHostHeaderOriginFallback: true,\n isLocalClient: false,\n });\n\n if (!result.ok) {\n log.warn(\n { origin, reason: 'reason' in result ? result.reason : 'unknown', path: c.req.path },\n 'Browser origin check failed',\n );\n return c.json({ error: 'Forbidden', message: 'Origin not allowed' }, 403);\n }\n\n return next();\n }));\n\n app.use('/api/skills/upload', bodyLimit({\n maxSize: 10 * 1024 * 1024,\n onError: (c) => {\n return c.json({ error: 'Skill package too large', maxSize: '10MB' }, 413);\n },\n }));\n\n const DEFAULT_API_BODY_MAX = 1 * 1024 * 1024;\n const WEBCHAT_AGENT_BODY_MAX = maxWebchatAgentRequestBodyBytes();\n\n app.use('/api/*', async (c, next) => {\n const maxSize = c.req.path === '/api/agent' ? WEBCHAT_AGENT_BODY_MAX : DEFAULT_API_BODY_MAX;\n const maxSizeMb = Math.ceil(maxSize / (1024 * 1024));\n return bodyLimit({\n maxSize,\n onError: (ctx) =>\n ctx.json({ error: 'Request body too large', maxSize: `${maxSizeMb}MB` }, 413),\n })(c, next);\n });\n\n registerPublicGatewayRoutes(app, service);\n\n // Extension UI assets are served without auth: sandboxed iframes (no allow-same-origin)\n // have an opaque origin of `null` and cannot forward the ?token= from the parent HTML URL.\n // Security is enforced by the strict CSP (frame-ancestors 'self') on every response.\n registerPublicExtensionAssetRoutes(app, service);\n\n const authenticated = new Hono();\n authenticated.use(\n auth({\n token,\n getGatewayAuth: () => service.currentConfig.gateway?.auth,\n }),\n );\n authenticated.use(operatorScopes());\n\n const strictRateLimiter = new Map<string, ReturnType<typeof createFixedWindowRateLimiter>>();\n\n const RATE_LIMIT_CLEANUP_INTERVAL = 5 * 60 * 1000;\n setInterval(() => {\n for (const [ip, limiter] of strictRateLimiter.entries()) {\n const result = limiter.consume();\n if (result.remaining === 9) {\n strictRateLimiter.delete(ip);\n }\n }\n }, RATE_LIMIT_CLEANUP_INTERVAL);\n\n const strictRateLimitMiddleware = createMiddleware(async (c, next) => {\n /*\n const clientIp = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()\n ?? c.req.header('x-real-ip')\n ?? 'unknown';\n\n let limiter = strictRateLimiter.get(clientIp);\n if (!limiter) {\n limiter = createFixedWindowRateLimiter({ maxRequests: 10, windowMs: 60_000 });\n strictRateLimiter.set(clientIp, limiter);\n }\n\n const result = limiter.consume();\n if (!result.allowed) {\n c.header('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));\n return c.json({ error: 'Too many requests' }, 429);\n }\n\n c.header('X-RateLimit-Remaining', String(result.remaining));\n */\n await next();\n });\n\n const sseConfig = {\n service,\n maxSseConnections: service.currentConfig.gateway.maxSseConnections,\n };\n\n registerAuthenticatedRoutes(authenticated, {\n service,\n strictRateLimitMiddleware,\n sseConfig,\n });\n\n app.route('/', authenticated);\n\n app.notFound((c) => {\n return c.json({ error: 'Not found' }, 404);\n });\n\n app.onError((err, c) => {\n log.error({ err }, 'Hono error');\n return c.json({ error: 'Internal server error' }, 500);\n });\n\n return app;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;aAMqD;AAarD,MAAM,MAAM,aAAa,UAAU;;;;;;AAYnC,SAAgB,8BAA8B,MAAuB;AACnE,QAAO,sCAAsC,KAAK,KAAK;;AAGzD,SAAgB,cAAc,QAA6B;CACzD,MAAM,EAAE,SAAS,UAAU;CAC3B,MAAM,MAAM,IAAI,MAAM;CAEtB,MAAM,cAAc,QAAQ,cAAc,QAAQ,QAAQ;CAC1D,MAAM,oBAAoB,QAAQ,cAAc,QAAQ;CAExD,IAAI;AACJ,KAAI,qBAAqB,kBAAkB,SAAS,EAClD,cAAa;KAEb,cAAa;EACX,oBAAoB;EACpB,oBAAoB;EACpB;EACA;EACD;CAGH,MAAM,eAAe;EACnB,QAAQ;EACR,cAAc;GAAC;GAAO;GAAQ;GAAS;GAAU;GAAU;EAC3D,cAAc;GAAC;GAAgB;GAAiB;GAAU;GAAgB;GAAgB;EAC1F,aAAa;EACb,QAAQ;EACT;AAED,KAAI,IAAI,sBAAsB,CAAC;AAC/B,KAAI,IAAI,QAAQ,CAAC;AACjB,KAAI,IAAI,KAAK,aAAa,CAAC;CAG3B,MAAM,oBAAoB,8BAA8B;AAGxD,KAAI,IAAI,iBAAiB,OAAO,GAAG,SAAS;AAC1C,QAAM,MAAM;AACZ,MAAI,8BAA8B,EAAE,IAAI,KAAK,CAC3C;AAEF,IAAE,OAAO,mBAAmB,OAAO;AACnC,IAAE,OAAO,0BAA0B,UAAU;AAC7C,IAAE,OAAO,mBAAmB,kCAAkC;AAC9D,IAAE,OAAO,oBAAoB,gBAAgB;AAE7C,IAAE,OAAO,sBAAsB,+CAA+C;AAC9E,IAAE,OAAO,2BAA2B,kBAAkB;GACtD,CAAC;CAKH,MAAM,iBAAiB,MAAM,QAAQ,WAAW,GAAG,aAAa,CAAC,WAAW;AAC5E,KAAI,IAAI,UAAU,iBAAiB,OAAO,GAAG,SAAS;EACpD,MAAM,SAAS,EAAE,IAAI,OAAO,SAAS;AACrC,MAAI,CAAC,OAEH,QAAO,MAAM;EAGf,MAAM,SAAS,mBAAmB;GAChC,aAAa,EAAE,IAAI,OAAO,OAAO;GACjC;GACA;GACA,+BAA+B;GAC/B,eAAe;GAChB,CAAC;AAEF,MAAI,CAAC,OAAO,IAAI;AACd,OAAI,KACF;IAAE;IAAQ,QAAQ,YAAY,SAAS,OAAO,SAAS;IAAW,MAAM,EAAE,IAAI;IAAM,EACpF,8BACD;AACD,UAAO,EAAE,KAAK;IAAE,OAAO;IAAa,SAAS;IAAsB,EAAE,IAAI;;AAG3E,SAAO,MAAM;GACb,CAAC;AAEH,KAAI,IAAI,sBAAsB,UAAU;EACtC,SAAS,KAAK,OAAO;EACrB,UAAU,MAAM;AACd,UAAO,EAAE,KAAK;IAAE,OAAO;IAA2B,SAAS;IAAQ,EAAE,IAAI;;EAE5E,CAAC,CAAC;CAEH,MAAM,uBAAuB,IAAI,OAAO;CACxC,MAAM,yBAAyB,iCAAiC;AAEhE,KAAI,IAAI,UAAU,OAAO,GAAG,SAAS;EACnC,MAAM,UAAU,EAAE,IAAI,SAAS,eAAe,yBAAyB;EACvE,MAAM,YAAY,KAAK,KAAK,WAAW,OAAO,MAAM;AACpD,SAAO,UAAU;GACf;GACA,UAAU,QACR,IAAI,KAAK;IAAE,OAAO;IAA0B,SAAS,GAAG,UAAU;IAAK,EAAE,IAAI;GAChF,CAAC,CAAC,GAAG,KAAK;GACX;AAEF,6BAA4B,KAAK,QAAQ;AAKzC,oCAAmC,KAAK,QAAQ;CAEhD,MAAM,gBAAgB,IAAI,MAAM;AAChC,eAAc,IACZ,KAAK;EACH;EACA,sBAAsB,QAAQ,cAAc,SAAS;EACtD,CAAC,CACH;AACD,eAAc,IAAI,gBAAgB,CAAC;CAEnC,MAAM,oCAAoB,IAAI,KAA8D;AAG5F,mBAAkB;AAChB,OAAK,MAAM,CAAC,IAAI,YAAY,kBAAkB,SAAS,CAErD,KADe,QAAQ,SACb,CAAC,cAAc,EACvB,mBAAkB,OAAO,GAAG;IALE,MAAS,IAQd;AA8B/B,6BAA4B,eAAe;EACzC;EACA,2BA9BgC,iBAAiB,OAAO,GAAG,SAAS;AAoBpE,SAAM,MAAM;IAUa;EACzB,WAAA;GAPA;GACA,mBAAmB,QAAQ,cAAc,QAAQ;GAMxC;EACV,CAAC;AAEF,KAAI,MAAM,KAAK,cAAc;AAE7B,KAAI,UAAU,MAAM;AAClB,SAAO,EAAE,KAAK,EAAE,OAAO,aAAa,EAAE,IAAI;GAC1C;AAEF,KAAI,SAAS,KAAK,MAAM;AACtB,MAAI,MAAM,EAAE,KAAK,EAAE,aAAa;AAChC,SAAO,EAAE,KAAK,EAAE,OAAO,yBAAyB,EAAE,IAAI;GACtD;AAEF,QAAO"}
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { createLogger } from "../../../utils/logger/index.js";
|
|
2
2
|
import { init_logger } from "../../../utils/logger.js";
|
|
3
|
+
import { safeEqualSecret } from "../../security/secret-equal.js";
|
|
3
4
|
import { getAuthFailureRateLimiter, getClientIpFromHeaders, isAuthRateLimitGloballyDisabled, resolveAuthRateLimitConfig } from "../../auth-rate-limit.js";
|
|
4
5
|
import { createMiddleware } from "hono/factory";
|
|
5
6
|
//#region src/gateway/hono/middleware/auth.ts
|
|
6
7
|
init_logger();
|
|
7
8
|
const log = createLogger("Hono:Auth");
|
|
8
9
|
/**
|
|
9
|
-
* Validate token
|
|
10
|
+
* Validate token using constant-time comparison to prevent timing attacks.
|
|
10
11
|
*/
|
|
11
12
|
function validateToken(providedToken, expectedToken) {
|
|
12
13
|
if (!providedToken) return false;
|
|
13
|
-
return providedToken
|
|
14
|
+
return safeEqualSecret(providedToken, expectedToken);
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
17
|
* Extract token from Authorization header
|
|
@@ -106,7 +107,7 @@ function validateWebSocketAuth(url, authHeader, expectedToken) {
|
|
|
106
107
|
error: "Missing authentication token"
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
|
-
if (!
|
|
110
|
+
if (!safeEqualSecret(providedToken, expectedToken)) {
|
|
110
111
|
log.warn({
|
|
111
112
|
path: url.pathname,
|
|
112
113
|
reason: "invalid_token"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","names":[],"sources":["../../../../../src/gateway/hono/middleware/auth.ts"],"sourcesContent":["import { createMiddleware } from 'hono/factory';\nimport type { GatewayAuthConfig } from '../../../config/schema.js';\nimport {\n getAuthFailureRateLimiter,\n getClientIpFromHeaders,\n isAuthRateLimitGloballyDisabled,\n resolveAuthRateLimitConfig,\n} from '../../auth-rate-limit.js';\nimport { createLogger } from '../../../utils/logger.js';\n\nconst log = createLogger('Hono:Auth');\n\nexport interface AuthConfig {\n token?: string;\n /** Current gateway auth from config (for rate-limit settings); optional. */\n getGatewayAuth?: () => GatewayAuthConfig | undefined;\n}\n\n/**\n * Validate token
|
|
1
|
+
{"version":3,"file":"auth.js","names":[],"sources":["../../../../../src/gateway/hono/middleware/auth.ts"],"sourcesContent":["import { createMiddleware } from 'hono/factory';\nimport type { GatewayAuthConfig } from '../../../config/schema.js';\nimport {\n getAuthFailureRateLimiter,\n getClientIpFromHeaders,\n isAuthRateLimitGloballyDisabled,\n resolveAuthRateLimitConfig,\n} from '../../auth-rate-limit.js';\nimport { safeEqualSecret } from '../../security/secret-equal.js';\nimport { createLogger } from '../../../utils/logger.js';\n\nconst log = createLogger('Hono:Auth');\n\nexport interface AuthConfig {\n token?: string;\n /** Current gateway auth from config (for rate-limit settings); optional. */\n getGatewayAuth?: () => GatewayAuthConfig | undefined;\n}\n\n/**\n * Validate token using constant-time comparison to prevent timing attacks.\n */\nfunction validateToken(providedToken: string | undefined, expectedToken: string): boolean {\n if (!providedToken) return false;\n return safeEqualSecret(providedToken, expectedToken);\n}\n\n/**\n * Extract token from Authorization header\n * Supports: \"Bearer <token>\", \"<token>\"\n */\nfunction extractTokenFromHeader(authHeader: string | null): string | null {\n if (!authHeader) return null;\n\n const parts = authHeader.split(' ');\n if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') {\n return parts[1];\n }\n return authHeader;\n}\n\n/**\n * Extract token from query parameter\n */\nfunction extractTokenFromQuery(url: string): string | null {\n const parsed = new URL(url);\n return parsed.searchParams.get('token');\n}\n\n/**\n * Create auth middleware for HTTP routes\n */\nexport function auth(config?: AuthConfig) {\n const { token, getGatewayAuth } = config || {};\n const limiter = getAuthFailureRateLimiter();\n\n return createMiddleware(async (c, next) => {\n // If no token configured, allow all\n if (!token) {\n return next();\n }\n\n const rlInput = getGatewayAuth?.()?.rateLimit;\n const rlCfg = resolveAuthRateLimitConfig(rlInput);\n const rateLimitActive =\n rlCfg.enabled && !isAuthRateLimitGloballyDisabled();\n\n const clientIp = getClientIpFromHeaders({\n get: (name: string) => c.req.header(name) ?? undefined,\n });\n\n // Try header first, then query param\n const authHeader = extractTokenFromHeader(c.req.header('authorization'));\n const queryToken = extractTokenFromQuery(c.req.url);\n\n const providedToken = authHeader || queryToken;\n\n // Allow valid credentials to pass immediately and clear historical failures for this client.\n // This avoids lockout after a user fixes token configuration.\n if (providedToken && validateToken(providedToken, token)) {\n if (rateLimitActive) {\n limiter.recordSuccess(clientIp);\n }\n await next();\n return;\n }\n\n if (rateLimitActive) {\n const blocked = limiter.checkBlocked(clientIp, rlCfg);\n if (blocked.blocked) {\n c.header('Retry-After', String(blocked.retryAfterSec));\n return c.json(\n {\n error: 'Too Many Requests',\n message: 'Too many authentication attempts',\n retryAfter: blocked.retryAfterSec,\n },\n 429,\n );\n }\n }\n\n if (!providedToken) {\n if (rateLimitActive) {\n limiter.recordFailure(clientIp, rlCfg);\n }\n log.warn(\n { path: c.req.path, method: c.req.method, clientIp, reason: 'missing_token' },\n 'HTTP auth rejected: no Bearer or ?token=',\n );\n return c.json({ error: 'Unauthorized', message: 'Missing authentication token' }, 401);\n }\n\n if (!validateToken(providedToken, token)) {\n if (rateLimitActive) {\n limiter.recordFailure(clientIp, rlCfg);\n }\n log.warn(\n { path: c.req.path, method: c.req.method, clientIp, reason: 'invalid_token' },\n 'HTTP auth rejected: token mismatch',\n );\n return c.json({ error: 'Unauthorized', message: 'Invalid authentication token' }, 401);\n }\n });\n}\n\nexport interface WebSocketAuthResult {\n valid: boolean;\n error?: string;\n}\n\n/**\n * Validate WebSocket connection token\n */\nexport function validateWebSocketAuth(\n url: URL,\n authHeader: string | null,\n expectedToken?: string\n): WebSocketAuthResult {\n // If no token configured, allow all\n if (!expectedToken) {\n return { valid: true };\n }\n\n // Extract token from query param or header\n const queryToken = url.searchParams.get('token');\n const headerToken = extractTokenFromHeader(authHeader);\n\n const providedToken = queryToken || headerToken;\n\n if (!providedToken) {\n log.warn(\n { path: url.pathname, reason: 'missing_token', hasHeaderToken: Boolean(headerToken) },\n 'WebSocket auth rejected: no token in query or Authorization',\n );\n return { valid: false, error: 'Missing authentication token' };\n }\n\n if (!safeEqualSecret(providedToken, expectedToken)) {\n log.warn({ path: url.pathname, reason: 'invalid_token' }, 'WebSocket auth rejected: token mismatch');\n return { valid: false, error: 'Invalid authentication token' };\n }\n\n return { valid: true };\n}\n"],"mappings":";;;;;;aASwD;AAExD,MAAM,MAAM,aAAa,YAAY;;;;AAWrC,SAAS,cAAc,eAAmC,eAAgC;AACxF,KAAI,CAAC,cAAe,QAAO;AAC3B,QAAO,gBAAgB,eAAe,cAAc;;;;;;AAOtD,SAAS,uBAAuB,YAA0C;AACxE,KAAI,CAAC,WAAY,QAAO;CAExB,MAAM,QAAQ,WAAW,MAAM,IAAI;AACnC,KAAI,MAAM,WAAW,KAAK,MAAM,GAAG,aAAa,KAAK,SACnD,QAAO,MAAM;AAEf,QAAO;;;;;AAMT,SAAS,sBAAsB,KAA4B;AAEzD,QAAO,IADY,IAAI,IACV,CAAC,aAAa,IAAI,QAAQ;;;;;AAMzC,SAAgB,KAAK,QAAqB;CACxC,MAAM,EAAE,OAAO,mBAAmB,UAAU,EAAE;CAC9C,MAAM,UAAU,2BAA2B;AAE3C,QAAO,iBAAiB,OAAO,GAAG,SAAS;AAEzC,MAAI,CAAC,MACH,QAAO,MAAM;EAGf,MAAM,UAAU,kBAAkB,EAAE;EACpC,MAAM,QAAQ,2BAA2B,QAAQ;EACjD,MAAM,kBACJ,MAAM,WAAW,CAAC,iCAAiC;EAErD,MAAM,WAAW,uBAAuB,EACtC,MAAM,SAAiB,EAAE,IAAI,OAAO,KAAK,IAAI,KAAA,GAC9C,CAAC;EAGF,MAAM,aAAa,uBAAuB,EAAE,IAAI,OAAO,gBAAgB,CAAC;EACxE,MAAM,aAAa,sBAAsB,EAAE,IAAI,IAAI;EAEnD,MAAM,gBAAgB,cAAc;AAIpC,MAAI,iBAAiB,cAAc,eAAe,MAAM,EAAE;AACxD,OAAI,gBACF,SAAQ,cAAc,SAAS;AAEjC,SAAM,MAAM;AACZ;;AAGF,MAAI,iBAAiB;GACnB,MAAM,UAAU,QAAQ,aAAa,UAAU,MAAM;AACrD,OAAI,QAAQ,SAAS;AACnB,MAAE,OAAO,eAAe,OAAO,QAAQ,cAAc,CAAC;AACtD,WAAO,EAAE,KACP;KACE,OAAO;KACP,SAAS;KACT,YAAY,QAAQ;KACrB,EACD,IACD;;;AAIL,MAAI,CAAC,eAAe;AAClB,OAAI,gBACF,SAAQ,cAAc,UAAU,MAAM;AAExC,OAAI,KACF;IAAE,MAAM,EAAE,IAAI;IAAM,QAAQ,EAAE,IAAI;IAAQ;IAAU,QAAQ;IAAiB,EAC7E,2CACD;AACD,UAAO,EAAE,KAAK;IAAE,OAAO;IAAgB,SAAS;IAAgC,EAAE,IAAI;;AAGxF,MAAI,CAAC,cAAc,eAAe,MAAM,EAAE;AACxC,OAAI,gBACF,SAAQ,cAAc,UAAU,MAAM;AAExC,OAAI,KACF;IAAE,MAAM,EAAE,IAAI;IAAM,QAAQ,EAAE,IAAI;IAAQ;IAAU,QAAQ;IAAiB,EAC7E,qCACD;AACD,UAAO,EAAE,KAAK;IAAE,OAAO;IAAgB,SAAS;IAAgC,EAAE,IAAI;;GAExF;;;;;AAWJ,SAAgB,sBACd,KACA,YACA,eACqB;AAErB,KAAI,CAAC,cACH,QAAO,EAAE,OAAO,MAAM;CAIxB,MAAM,aAAa,IAAI,aAAa,IAAI,QAAQ;CAChD,MAAM,cAAc,uBAAuB,WAAW;CAEtD,MAAM,gBAAgB,cAAc;AAEpC,KAAI,CAAC,eAAe;AAClB,MAAI,KACF;GAAE,MAAM,IAAI;GAAU,QAAQ;GAAiB,gBAAgB,QAAQ,YAAY;GAAE,EACrF,8DACD;AACD,SAAO;GAAE,OAAO;GAAO,OAAO;GAAgC;;AAGhE,KAAI,CAAC,gBAAgB,eAAe,cAAc,EAAE;AAClD,MAAI,KAAK;GAAE,MAAM,IAAI;GAAU,QAAQ;GAAiB,EAAE,0CAA0C;AACpG,SAAO;GAAE,OAAO;GAAO,OAAO;GAAgC;;AAGhE,QAAO,EAAE,OAAO,MAAM"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware that enforces operator scope requirements on API routes.
|
|
3
|
+
*
|
|
4
|
+
* This middleware runs AFTER the auth middleware. For authenticated requests,
|
|
5
|
+
* it checks that the declared operator scopes (defaulting to full access for
|
|
6
|
+
* token-authenticated users) satisfy the minimum scope required by the route.
|
|
7
|
+
*
|
|
8
|
+
* The scope system is currently implicit: all authenticated users get
|
|
9
|
+
* DEFAULT_OPERATOR_SCOPES. This lays the groundwork for future per-device
|
|
10
|
+
* or per-token scope restrictions (similar to OpenClaw's device pairing scopes).
|
|
11
|
+
*/
|
|
12
|
+
export declare function operatorScopes(): import("hono/dist/types/types.js").MiddlewareHandler<any, string, {}, Response & import("hono/dist/types/types.js").TypedResponse<{
|
|
13
|
+
error: string;
|
|
14
|
+
message: string;
|
|
15
|
+
}, 403, "json">>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createLogger } from "../../../utils/logger/index.js";
|
|
2
|
+
import { init_logger } from "../../../utils/logger.js";
|
|
3
|
+
import { DEFAULT_OPERATOR_SCOPES, authorizeRouteScope } from "../../security/operator-scopes.js";
|
|
4
|
+
import { createMiddleware } from "hono/factory";
|
|
5
|
+
//#region src/gateway/hono/middleware/scopes.ts
|
|
6
|
+
init_logger();
|
|
7
|
+
const log = createLogger("Hono:Scopes");
|
|
8
|
+
/**
|
|
9
|
+
* Middleware that enforces operator scope requirements on API routes.
|
|
10
|
+
*
|
|
11
|
+
* This middleware runs AFTER the auth middleware. For authenticated requests,
|
|
12
|
+
* it checks that the declared operator scopes (defaulting to full access for
|
|
13
|
+
* token-authenticated users) satisfy the minimum scope required by the route.
|
|
14
|
+
*
|
|
15
|
+
* The scope system is currently implicit: all authenticated users get
|
|
16
|
+
* DEFAULT_OPERATOR_SCOPES. This lays the groundwork for future per-device
|
|
17
|
+
* or per-token scope restrictions (similar to OpenClaw's device pairing scopes).
|
|
18
|
+
*/
|
|
19
|
+
function operatorScopes() {
|
|
20
|
+
return createMiddleware(async (c, next) => {
|
|
21
|
+
const grantedScopes = [...DEFAULT_OPERATOR_SCOPES];
|
|
22
|
+
const scopeCheck = authorizeRouteScope(c.req.path, grantedScopes);
|
|
23
|
+
if (!scopeCheck.allowed && "requiredScope" in scopeCheck) {
|
|
24
|
+
const { requiredScope } = scopeCheck;
|
|
25
|
+
log.warn({
|
|
26
|
+
path: c.req.path,
|
|
27
|
+
method: c.req.method,
|
|
28
|
+
requiredScope
|
|
29
|
+
}, `Scope check failed: missing ${requiredScope}`);
|
|
30
|
+
return c.json({
|
|
31
|
+
error: "Forbidden",
|
|
32
|
+
message: `Missing required scope: ${requiredScope}`
|
|
33
|
+
}, 403);
|
|
34
|
+
}
|
|
35
|
+
await next();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { operatorScopes };
|
|
40
|
+
|
|
41
|
+
//# sourceMappingURL=scopes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scopes.js","names":[],"sources":["../../../../../src/gateway/hono/middleware/scopes.ts"],"sourcesContent":["import { createMiddleware } from 'hono/factory';\nimport { authorizeRouteScope, DEFAULT_OPERATOR_SCOPES, type OperatorScope } from '../../security/operator-scopes.js';\nimport { createLogger } from '../../../utils/logger.js';\n\nconst log = createLogger('Hono:Scopes');\n\n/**\n * Middleware that enforces operator scope requirements on API routes.\n *\n * This middleware runs AFTER the auth middleware. For authenticated requests,\n * it checks that the declared operator scopes (defaulting to full access for\n * token-authenticated users) satisfy the minimum scope required by the route.\n *\n * The scope system is currently implicit: all authenticated users get\n * DEFAULT_OPERATOR_SCOPES. This lays the groundwork for future per-device\n * or per-token scope restrictions (similar to OpenClaw's device pairing scopes).\n */\nexport function operatorScopes() {\n return createMiddleware(async (c, next) => {\n // All currently authenticated users get full operator scopes.\n // In the future, device-paired connections or scoped tokens can provide\n // a narrower set via x-xopc-scopes header or connect payload.\n const grantedScopes: OperatorScope[] = [...DEFAULT_OPERATOR_SCOPES];\n\n const scopeCheck = authorizeRouteScope(c.req.path, grantedScopes);\n if (!scopeCheck.allowed && 'requiredScope' in scopeCheck) {\n const { requiredScope } = scopeCheck;\n log.warn(\n { path: c.req.path, method: c.req.method, requiredScope },\n `Scope check failed: missing ${requiredScope}`,\n );\n return c.json(\n {\n error: 'Forbidden',\n message: `Missing required scope: ${requiredScope}`,\n },\n 403,\n );\n }\n\n await next();\n });\n}\n"],"mappings":";;;;;aAEwD;AAExD,MAAM,MAAM,aAAa,cAAc;;;;;;;;;;;;AAavC,SAAgB,iBAAiB;AAC/B,QAAO,iBAAiB,OAAO,GAAG,SAAS;EAIzC,MAAM,gBAAiC,CAAC,GAAG,wBAAwB;EAEnE,MAAM,aAAa,oBAAoB,EAAE,IAAI,MAAM,cAAc;AACjE,MAAI,CAAC,WAAW,WAAW,mBAAmB,YAAY;GACxD,MAAM,EAAE,kBAAkB;AAC1B,OAAI,KACF;IAAE,MAAM,EAAE,IAAI;IAAM,QAAQ,EAAE,IAAI;IAAQ;IAAe,EACzD,+BAA+B,gBAChC;AACD,UAAO,EAAE,KACP;IACE,OAAO;IACP,SAAS,2BAA2B;IACrC,EACD,IACD;;AAGH,QAAM,MAAM;GACZ"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ResolvedGatewayAuth } from '../auth.js';
|
|
2
|
+
export type SecurityAuditFinding = {
|
|
3
|
+
checkId: string;
|
|
4
|
+
severity: 'critical' | 'warn' | 'info';
|
|
5
|
+
title: string;
|
|
6
|
+
detail: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Audit the gateway configuration at startup and log security findings.
|
|
10
|
+
*
|
|
11
|
+
* This provides an early-warning system similar to OpenClaw's `security audit`
|
|
12
|
+
* command, adapted for xopc's configuration surface.
|
|
13
|
+
*/
|
|
14
|
+
export declare function auditGatewayConfig(params: {
|
|
15
|
+
auth: ResolvedGatewayAuth;
|
|
16
|
+
host?: string;
|
|
17
|
+
corsOrigins?: string[];
|
|
18
|
+
}): SecurityAuditFinding[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createLogger } from "../../utils/logger/index.js";
|
|
2
|
+
import { init_logger } from "../../utils/logger.js";
|
|
3
|
+
//#region src/gateway/security/audit.ts
|
|
4
|
+
init_logger();
|
|
5
|
+
const log = createLogger("SecurityAudit");
|
|
6
|
+
/**
|
|
7
|
+
* Audit the gateway configuration at startup and log security findings.
|
|
8
|
+
*
|
|
9
|
+
* This provides an early-warning system similar to OpenClaw's `security audit`
|
|
10
|
+
* command, adapted for xopc's configuration surface.
|
|
11
|
+
*/
|
|
12
|
+
function auditGatewayConfig(params) {
|
|
13
|
+
const findings = [];
|
|
14
|
+
if (params.auth.mode === "none") if (!(!params.host || params.host === "127.0.0.1" || params.host === "localhost" || params.host === "::1")) findings.push({
|
|
15
|
+
checkId: "gateway.auth.none_on_network",
|
|
16
|
+
severity: "critical",
|
|
17
|
+
title: "Gateway has no authentication on a network-accessible address",
|
|
18
|
+
detail: `Auth mode is "none" but gateway binds to ${params.host}. Any host on the network can access the gateway without credentials. Set gateway.auth.mode to "token" and configure a token.`
|
|
19
|
+
});
|
|
20
|
+
else findings.push({
|
|
21
|
+
checkId: "gateway.auth.none_loopback",
|
|
22
|
+
severity: "warn",
|
|
23
|
+
title: "Gateway authentication is disabled",
|
|
24
|
+
detail: "Auth mode is \"none\". This is acceptable for local development but should not be used in production."
|
|
25
|
+
});
|
|
26
|
+
if (params.corsOrigins?.includes("*")) findings.push({
|
|
27
|
+
checkId: "gateway.cors.wildcard",
|
|
28
|
+
severity: "warn",
|
|
29
|
+
title: "CORS allows all origins",
|
|
30
|
+
detail: "corsOrigins includes \"*\". Any website can make authenticated API calls to the gateway if it can obtain the token."
|
|
31
|
+
});
|
|
32
|
+
if (params.corsOrigins && params.corsOrigins.length > 20) findings.push({
|
|
33
|
+
checkId: "gateway.cors.excessive_origins",
|
|
34
|
+
severity: "info",
|
|
35
|
+
title: "Large number of CORS origins configured",
|
|
36
|
+
detail: `${params.corsOrigins.length} CORS origins configured. Review whether all are necessary.`
|
|
37
|
+
});
|
|
38
|
+
if (params.auth.mode === "token" && params.auth.token) {
|
|
39
|
+
if (!process.env.XOPC_GATEWAY_TOKEN) findings.push({
|
|
40
|
+
checkId: "gateway.auth.auto_generated_token",
|
|
41
|
+
severity: "info",
|
|
42
|
+
title: "Gateway token was auto-generated",
|
|
43
|
+
detail: "No explicit XOPC_GATEWAY_TOKEN set. The token was auto-generated and will change on each restart. Set XOPC_GATEWAY_TOKEN for a stable token."
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
for (const finding of findings) {
|
|
47
|
+
const logData = {
|
|
48
|
+
checkId: finding.checkId,
|
|
49
|
+
detail: finding.detail
|
|
50
|
+
};
|
|
51
|
+
switch (finding.severity) {
|
|
52
|
+
case "critical":
|
|
53
|
+
log.error(logData, `Security audit: ${finding.title}`);
|
|
54
|
+
break;
|
|
55
|
+
case "warn":
|
|
56
|
+
log.warn(logData, `Security audit: ${finding.title}`);
|
|
57
|
+
break;
|
|
58
|
+
case "info":
|
|
59
|
+
log.info(logData, `Security audit: ${finding.title}`);
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return findings;
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { auditGatewayConfig };
|
|
67
|
+
|
|
68
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audit.js","names":[],"sources":["../../../../src/gateway/security/audit.ts"],"sourcesContent":["import type { ResolvedGatewayAuth } from '../auth.js';\nimport { createLogger } from '../../utils/logger.js';\n\nconst log = createLogger('SecurityAudit');\n\nexport type SecurityAuditFinding = {\n checkId: string;\n severity: 'critical' | 'warn' | 'info';\n title: string;\n detail: string;\n};\n\n/**\n * Audit the gateway configuration at startup and log security findings.\n *\n * This provides an early-warning system similar to OpenClaw's `security audit`\n * command, adapted for xopc's configuration surface.\n */\nexport function auditGatewayConfig(params: {\n auth: ResolvedGatewayAuth;\n host?: string;\n corsOrigins?: string[];\n}): SecurityAuditFinding[] {\n const findings: SecurityAuditFinding[] = [];\n\n // Check: no auth on non-loopback bind\n if (params.auth.mode === 'none') {\n const isLoopback = !params.host ||\n params.host === '127.0.0.1' ||\n params.host === 'localhost' ||\n params.host === '::1';\n\n if (!isLoopback) {\n findings.push({\n checkId: 'gateway.auth.none_on_network',\n severity: 'critical',\n title: 'Gateway has no authentication on a network-accessible address',\n detail: `Auth mode is \"none\" but gateway binds to ${params.host}. ` +\n 'Any host on the network can access the gateway without credentials. ' +\n 'Set gateway.auth.mode to \"token\" and configure a token.',\n });\n } else {\n findings.push({\n checkId: 'gateway.auth.none_loopback',\n severity: 'warn',\n title: 'Gateway authentication is disabled',\n detail: 'Auth mode is \"none\". This is acceptable for local development ' +\n 'but should not be used in production.',\n });\n }\n }\n\n // Check: wildcard CORS origins\n if (params.corsOrigins?.includes('*')) {\n findings.push({\n checkId: 'gateway.cors.wildcard',\n severity: 'warn',\n title: 'CORS allows all origins',\n detail: 'corsOrigins includes \"*\". Any website can make authenticated API calls ' +\n 'to the gateway if it can obtain the token.',\n });\n }\n\n // Check: too many CORS origins may indicate misconfiguration\n if (params.corsOrigins && params.corsOrigins.length > 20) {\n findings.push({\n checkId: 'gateway.cors.excessive_origins',\n severity: 'info',\n title: 'Large number of CORS origins configured',\n detail: `${params.corsOrigins.length} CORS origins configured. Review whether all are necessary.`,\n });\n }\n\n // Check: token mode without explicit token (auto-generated)\n if (params.auth.mode === 'token' && params.auth.token) {\n const envToken = process.env.XOPC_GATEWAY_TOKEN;\n if (!envToken) {\n findings.push({\n checkId: 'gateway.auth.auto_generated_token',\n severity: 'info',\n title: 'Gateway token was auto-generated',\n detail: 'No explicit XOPC_GATEWAY_TOKEN set. The token was auto-generated and will ' +\n 'change on each restart. Set XOPC_GATEWAY_TOKEN for a stable token.',\n });\n }\n }\n\n // Emit findings as log entries\n for (const finding of findings) {\n const logData = { checkId: finding.checkId, detail: finding.detail };\n switch (finding.severity) {\n case 'critical':\n log.error(logData, `Security audit: ${finding.title}`);\n break;\n case 'warn':\n log.warn(logData, `Security audit: ${finding.title}`);\n break;\n case 'info':\n log.info(logData, `Security audit: ${finding.title}`);\n break;\n }\n }\n\n return findings;\n}\n"],"mappings":";;;aACqD;AAErD,MAAM,MAAM,aAAa,gBAAgB;;;;;;;AAezC,SAAgB,mBAAmB,QAIR;CACzB,MAAM,WAAmC,EAAE;AAG3C,KAAI,OAAO,KAAK,SAAS,OAMvB,KAAI,EALe,CAAC,OAAO,QACzB,OAAO,SAAS,eAChB,OAAO,SAAS,eAChB,OAAO,SAAS,OAGhB,UAAS,KAAK;EACZ,SAAS;EACT,UAAU;EACV,OAAO;EACP,QAAQ,4CAA4C,OAAO,KAAK;EAGjE,CAAC;KAEF,UAAS,KAAK;EACZ,SAAS;EACT,UAAU;EACV,OAAO;EACP,QAAQ;EAET,CAAC;AAKN,KAAI,OAAO,aAAa,SAAS,IAAI,CACnC,UAAS,KAAK;EACZ,SAAS;EACT,UAAU;EACV,OAAO;EACP,QAAQ;EAET,CAAC;AAIJ,KAAI,OAAO,eAAe,OAAO,YAAY,SAAS,GACpD,UAAS,KAAK;EACZ,SAAS;EACT,UAAU;EACV,OAAO;EACP,QAAQ,GAAG,OAAO,YAAY,OAAO;EACtC,CAAC;AAIJ,KAAI,OAAO,KAAK,SAAS,WAAW,OAAO,KAAK;MAE1C,CADa,QAAQ,IAAI,mBAE3B,UAAS,KAAK;GACZ,SAAS;GACT,UAAU;GACV,OAAO;GACP,QAAQ;GAET,CAAC;;AAKN,MAAK,MAAM,WAAW,UAAU;EAC9B,MAAM,UAAU;GAAE,SAAS,QAAQ;GAAS,QAAQ,QAAQ;GAAQ;AACpE,UAAQ,QAAQ,UAAhB;GACE,KAAK;AACH,QAAI,MAAM,SAAS,mBAAmB,QAAQ,QAAQ;AACtD;GACF,KAAK;AACH,QAAI,KAAK,SAAS,mBAAmB,QAAQ,QAAQ;AACrD;GACF,KAAK;AACH,QAAI,KAAK,SAAS,mBAAmB,QAAQ,QAAQ;AACrD;;;AAIN,QAAO"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute SHA-256 CSP hashes for inline `<script>` blocks in an HTML string.
|
|
3
|
+
* Only scripts without a `src` attribute are considered inline.
|
|
4
|
+
*/
|
|
5
|
+
export declare function computeInlineScriptHashes(html: string): string[];
|
|
6
|
+
/**
|
|
7
|
+
* Build a Content-Security-Policy header string.
|
|
8
|
+
*
|
|
9
|
+
* For the gateway console, we use:
|
|
10
|
+
* - `script-src 'self'` + optional SHA-256 hashes for inline scripts (no unsafe-inline)
|
|
11
|
+
* - `style-src 'self' 'unsafe-inline'` (Tailwind + runtime style injection)
|
|
12
|
+
* - `frame-ancestors 'none'` (prevent clickjacking)
|
|
13
|
+
* - `base-uri 'none'` (prevent base tag hijacking)
|
|
14
|
+
* - `object-src 'none'` (prevent plugin execution)
|
|
15
|
+
*/
|
|
16
|
+
export declare function buildGatewayConsoleCspHeader(options?: {
|
|
17
|
+
inlineScriptHashes?: string[];
|
|
18
|
+
connectSrc?: string;
|
|
19
|
+
}): string;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
//#region src/gateway/security/csp.ts
|
|
3
|
+
/**
|
|
4
|
+
* Compute SHA-256 CSP hashes for inline `<script>` blocks in an HTML string.
|
|
5
|
+
* Only scripts without a `src` attribute are considered inline.
|
|
6
|
+
*/
|
|
7
|
+
function computeInlineScriptHashes(html) {
|
|
8
|
+
const hashes = [];
|
|
9
|
+
const scriptRegex = /<script(?:\s[^>]*)?>([^]*?)<\/script>/gi;
|
|
10
|
+
let match;
|
|
11
|
+
while ((match = scriptRegex.exec(html)) !== null) {
|
|
12
|
+
if (hasSrcAttribute(match[0].slice(0, match[0].indexOf(">") + 1))) continue;
|
|
13
|
+
const content = match[1];
|
|
14
|
+
if (!content) continue;
|
|
15
|
+
const hash = createHash("sha256").update(content, "utf8").digest("base64");
|
|
16
|
+
hashes.push(`sha256-${hash}`);
|
|
17
|
+
}
|
|
18
|
+
return hashes;
|
|
19
|
+
}
|
|
20
|
+
const ATTRIBUTE_NAME_REGEX = /\s([^\s=/>]+)(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+))?/g;
|
|
21
|
+
function hasSrcAttribute(openTag) {
|
|
22
|
+
return Array.from(openTag.matchAll(ATTRIBUTE_NAME_REGEX)).some((attrMatch) => attrMatch[1]?.toLowerCase() === "src");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Build a Content-Security-Policy header string.
|
|
26
|
+
*
|
|
27
|
+
* For the gateway console, we use:
|
|
28
|
+
* - `script-src 'self'` + optional SHA-256 hashes for inline scripts (no unsafe-inline)
|
|
29
|
+
* - `style-src 'self' 'unsafe-inline'` (Tailwind + runtime style injection)
|
|
30
|
+
* - `frame-ancestors 'none'` (prevent clickjacking)
|
|
31
|
+
* - `base-uri 'none'` (prevent base tag hijacking)
|
|
32
|
+
* - `object-src 'none'` (prevent plugin execution)
|
|
33
|
+
*/
|
|
34
|
+
function buildGatewayConsoleCspHeader(options) {
|
|
35
|
+
const hashes = options?.inlineScriptHashes;
|
|
36
|
+
return [
|
|
37
|
+
"default-src 'self'",
|
|
38
|
+
"base-uri 'none'",
|
|
39
|
+
"object-src 'none'",
|
|
40
|
+
"frame-ancestors 'none'",
|
|
41
|
+
hashes?.length ? `script-src 'self' ${hashes.map((hash) => `'${hash}'`).join(" ")}` : "script-src 'self'",
|
|
42
|
+
"style-src 'self' 'unsafe-inline'",
|
|
43
|
+
"img-src 'self' data: blob: https:",
|
|
44
|
+
"font-src 'self'",
|
|
45
|
+
`connect-src ${options?.connectSrc ?? "'self' ws: wss:"}`,
|
|
46
|
+
"worker-src 'self'"
|
|
47
|
+
].join("; ");
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
export { buildGatewayConsoleCspHeader, computeInlineScriptHashes };
|
|
51
|
+
|
|
52
|
+
//# sourceMappingURL=csp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"csp.js","names":[],"sources":["../../../../src/gateway/security/csp.ts"],"sourcesContent":["import { createHash } from 'node:crypto';\n\n/**\n * Compute SHA-256 CSP hashes for inline `<script>` blocks in an HTML string.\n * Only scripts without a `src` attribute are considered inline.\n */\nexport function computeInlineScriptHashes(html: string): string[] {\n const hashes: string[] = [];\n const scriptRegex = /<script(?:\\s[^>]*)?>([^]*?)<\\/script>/gi;\n let match: RegExpExecArray | null;\n while ((match = scriptRegex.exec(html)) !== null) {\n const openTag = match[0].slice(0, match[0].indexOf('>') + 1);\n if (hasSrcAttribute(openTag)) {\n continue;\n }\n const content = match[1];\n if (!content) {\n continue;\n }\n const hash = createHash('sha256').update(content, 'utf8').digest('base64');\n hashes.push(`sha256-${hash}`);\n }\n return hashes;\n}\n\nconst ATTRIBUTE_NAME_REGEX = /\\s([^\\s=/>]+)(?:\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s>]+))?/g;\n\nfunction hasSrcAttribute(openTag: string): boolean {\n return Array.from(openTag.matchAll(ATTRIBUTE_NAME_REGEX)).some(\n (attrMatch) => attrMatch[1]?.toLowerCase() === 'src',\n );\n}\n\n/**\n * Build a Content-Security-Policy header string.\n *\n * For the gateway console, we use:\n * - `script-src 'self'` + optional SHA-256 hashes for inline scripts (no unsafe-inline)\n * - `style-src 'self' 'unsafe-inline'` (Tailwind + runtime style injection)\n * - `frame-ancestors 'none'` (prevent clickjacking)\n * - `base-uri 'none'` (prevent base tag hijacking)\n * - `object-src 'none'` (prevent plugin execution)\n */\nexport function buildGatewayConsoleCspHeader(options?: {\n inlineScriptHashes?: string[];\n connectSrc?: string;\n}): string {\n const hashes = options?.inlineScriptHashes;\n const scriptSrc = hashes?.length\n ? `script-src 'self' ${hashes.map((hash) => `'${hash}'`).join(' ')}`\n : \"script-src 'self'\";\n const connectSrc = options?.connectSrc ?? \"'self' ws: wss:\";\n\n return [\n \"default-src 'self'\",\n \"base-uri 'none'\",\n \"object-src 'none'\",\n \"frame-ancestors 'none'\",\n scriptSrc,\n \"style-src 'self' 'unsafe-inline'\",\n \"img-src 'self' data: blob: https:\",\n \"font-src 'self'\",\n `connect-src ${connectSrc}`,\n \"worker-src 'self'\",\n ].join('; ');\n}\n"],"mappings":";;;;;;AAMA,SAAgB,0BAA0B,MAAwB;CAChE,MAAM,SAAmB,EAAE;CAC3B,MAAM,cAAc;CACpB,IAAI;AACJ,SAAQ,QAAQ,YAAY,KAAK,KAAK,MAAM,MAAM;AAEhD,MAAI,gBADY,MAAM,GAAG,MAAM,GAAG,MAAM,GAAG,QAAQ,IAAI,GAAG,EAC/B,CAAC,CAC1B;EAEF,MAAM,UAAU,MAAM;AACtB,MAAI,CAAC,QACH;EAEF,MAAM,OAAO,WAAW,SAAS,CAAC,OAAO,SAAS,OAAO,CAAC,OAAO,SAAS;AAC1E,SAAO,KAAK,UAAU,OAAO;;AAE/B,QAAO;;AAGT,MAAM,uBAAuB;AAE7B,SAAS,gBAAgB,SAA0B;AACjD,QAAO,MAAM,KAAK,QAAQ,SAAS,qBAAqB,CAAC,CAAC,MACvD,cAAc,UAAU,IAAI,aAAa,KAAK,MAChD;;;;;;;;;;;;AAaH,SAAgB,6BAA6B,SAGlC;CACT,MAAM,SAAS,SAAS;AAMxB,QAAO;EACL;EACA;EACA;EACA;EATgB,QAAQ,SACtB,qBAAqB,OAAO,KAAK,SAAS,IAAI,KAAK,GAAG,CAAC,KAAK,IAAI,KAChE;EASF;EACA;EACA;EACA,eAXiB,SAAS,cAAc;EAYxC;EACD,CAAC,KAAK,KAAK"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools denied via Gateway HTTP tool invoke endpoints by default.
|
|
3
|
+
*
|
|
4
|
+
* These are high-risk because they enable command execution, file mutation,
|
|
5
|
+
* session orchestration, or control-plane actions that don't belong on a
|
|
6
|
+
* non-interactive HTTP surface.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_GATEWAY_HTTP_TOOL_DENY: readonly string[];
|
|
9
|
+
/**
|
|
10
|
+
* Check whether a tool name is in the default HTTP deny list.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isDangerousHttpTool(toolName: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Filter a list of tool names, removing those on the HTTP deny list.
|
|
15
|
+
* Returns only the safe tools.
|
|
16
|
+
*/
|
|
17
|
+
export declare function filterDangerousHttpTools(toolNames: string[]): {
|
|
18
|
+
allowed: string[];
|
|
19
|
+
denied: string[];
|
|
20
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//#region src/gateway/security/dangerous-tools.ts
|
|
2
|
+
/**
|
|
3
|
+
* Tools denied via Gateway HTTP tool invoke endpoints by default.
|
|
4
|
+
*
|
|
5
|
+
* These are high-risk because they enable command execution, file mutation,
|
|
6
|
+
* session orchestration, or control-plane actions that don't belong on a
|
|
7
|
+
* non-interactive HTTP surface.
|
|
8
|
+
*/
|
|
9
|
+
const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [
|
|
10
|
+
"exec",
|
|
11
|
+
"spawn",
|
|
12
|
+
"shell",
|
|
13
|
+
"fs_write",
|
|
14
|
+
"fs_delete",
|
|
15
|
+
"fs_move",
|
|
16
|
+
"apply_patch",
|
|
17
|
+
"sessions_spawn",
|
|
18
|
+
"sessions_send",
|
|
19
|
+
"cron",
|
|
20
|
+
"gateway"
|
|
21
|
+
];
|
|
22
|
+
const DANGEROUS_TOOL_SET = new Set(DEFAULT_GATEWAY_HTTP_TOOL_DENY);
|
|
23
|
+
/**
|
|
24
|
+
* Check whether a tool name is in the default HTTP deny list.
|
|
25
|
+
*/
|
|
26
|
+
function isDangerousHttpTool(toolName) {
|
|
27
|
+
return DANGEROUS_TOOL_SET.has(toolName.toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Filter a list of tool names, removing those on the HTTP deny list.
|
|
31
|
+
* Returns only the safe tools.
|
|
32
|
+
*/
|
|
33
|
+
function filterDangerousHttpTools(toolNames) {
|
|
34
|
+
const allowed = [];
|
|
35
|
+
const denied = [];
|
|
36
|
+
for (const name of toolNames) if (isDangerousHttpTool(name)) denied.push(name);
|
|
37
|
+
else allowed.push(name);
|
|
38
|
+
return {
|
|
39
|
+
allowed,
|
|
40
|
+
denied
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { DEFAULT_GATEWAY_HTTP_TOOL_DENY, filterDangerousHttpTools, isDangerousHttpTool };
|
|
45
|
+
|
|
46
|
+
//# sourceMappingURL=dangerous-tools.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dangerous-tools.js","names":[],"sources":["../../../../src/gateway/security/dangerous-tools.ts"],"sourcesContent":["/**\n * Tools denied via Gateway HTTP tool invoke endpoints by default.\n *\n * These are high-risk because they enable command execution, file mutation,\n * session orchestration, or control-plane actions that don't belong on a\n * non-interactive HTTP surface.\n */\nexport const DEFAULT_GATEWAY_HTTP_TOOL_DENY: readonly string[] = [\n // Direct command execution — immediate RCE surface\n 'exec',\n // Arbitrary child process creation\n 'spawn',\n // Shell command execution\n 'shell',\n // Arbitrary file mutation on the host\n 'fs_write',\n // Arbitrary file deletion on the host\n 'fs_delete',\n // Arbitrary file move/rename on the host\n 'fs_move',\n // Patch application can rewrite arbitrary files\n 'apply_patch',\n // Session orchestration — spawning agents remotely is RCE\n 'sessions_spawn',\n // Cross-session injection — message injection across sessions\n 'sessions_send',\n // Persistent automation control plane — can create/update/remove scheduled runs\n 'cron',\n // Gateway control plane — prevents gateway reconfiguration via HTTP\n 'gateway',\n] as const;\n\nconst DANGEROUS_TOOL_SET: ReadonlySet<string> = new Set(DEFAULT_GATEWAY_HTTP_TOOL_DENY);\n\n/**\n * Check whether a tool name is in the default HTTP deny list.\n */\nexport function isDangerousHttpTool(toolName: string): boolean {\n return DANGEROUS_TOOL_SET.has(toolName.toLowerCase());\n}\n\n/**\n * Filter a list of tool names, removing those on the HTTP deny list.\n * Returns only the safe tools.\n */\nexport function filterDangerousHttpTools(toolNames: string[]): {\n allowed: string[];\n denied: string[];\n} {\n const allowed: string[] = [];\n const denied: string[] = [];\n for (const name of toolNames) {\n if (isDangerousHttpTool(name)) {\n denied.push(name);\n } else {\n allowed.push(name);\n }\n }\n return { allowed, denied };\n}\n"],"mappings":";;;;;;;;AAOA,MAAa,iCAAoD;CAE/D;CAEA;CAEA;CAEA;CAEA;CAEA;CAEA;CAEA;CAEA;CAEA;CAEA;CACD;AAED,MAAM,qBAA0C,IAAI,IAAI,+BAA+B;;;;AAKvF,SAAgB,oBAAoB,UAA2B;AAC7D,QAAO,mBAAmB,IAAI,SAAS,aAAa,CAAC;;;;;;AAOvD,SAAgB,yBAAyB,WAGvC;CACA,MAAM,UAAoB,EAAE;CAC5B,MAAM,SAAmB,EAAE;AAC3B,MAAK,MAAM,QAAQ,UACjB,KAAI,oBAAoB,KAAK,CAC3B,QAAO,KAAK,KAAK;KAEjB,SAAQ,KAAK,KAAK;AAGtB,QAAO;EAAE;EAAS;EAAQ"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unauthorized flood guard for WebSocket connections.
|
|
3
|
+
*
|
|
4
|
+
* Tracks repeated unauthorized message attempts on a single WS connection
|
|
5
|
+
* and decides when to forcibly close it. Prevents a rogue client from
|
|
6
|
+
* holding a socket open and spamming unauthorized requests.
|
|
7
|
+
*/
|
|
8
|
+
export type FloodGuardOptions = {
|
|
9
|
+
/** Close the connection after this many unauthorized messages. @default 10 */
|
|
10
|
+
closeAfter?: number;
|
|
11
|
+
/** Log every N unauthorized messages to prevent log spam. @default 100 */
|
|
12
|
+
logEvery?: number;
|
|
13
|
+
};
|
|
14
|
+
export type FloodGuardDecision = {
|
|
15
|
+
shouldClose: boolean;
|
|
16
|
+
shouldLog: boolean;
|
|
17
|
+
count: number;
|
|
18
|
+
suppressedSinceLastLog: number;
|
|
19
|
+
};
|
|
20
|
+
export declare class UnauthorizedFloodGuard {
|
|
21
|
+
private readonly closeAfter;
|
|
22
|
+
private readonly logEvery;
|
|
23
|
+
private count;
|
|
24
|
+
private suppressedSinceLastLog;
|
|
25
|
+
constructor(options?: FloodGuardOptions);
|
|
26
|
+
registerUnauthorized(): FloodGuardDecision;
|
|
27
|
+
reset(): void;
|
|
28
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//#region src/gateway/security/flood-guard.ts
|
|
2
|
+
const DEFAULT_CLOSE_AFTER = 10;
|
|
3
|
+
const DEFAULT_LOG_EVERY = 100;
|
|
4
|
+
var UnauthorizedFloodGuard = class {
|
|
5
|
+
closeAfter;
|
|
6
|
+
logEvery;
|
|
7
|
+
count = 0;
|
|
8
|
+
suppressedSinceLastLog = 0;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.closeAfter = Math.max(1, Math.floor(options?.closeAfter ?? DEFAULT_CLOSE_AFTER));
|
|
11
|
+
this.logEvery = Math.max(1, Math.floor(options?.logEvery ?? DEFAULT_LOG_EVERY));
|
|
12
|
+
}
|
|
13
|
+
registerUnauthorized() {
|
|
14
|
+
this.count += 1;
|
|
15
|
+
const shouldClose = this.count > this.closeAfter;
|
|
16
|
+
if (!(this.count === 1 || this.count % this.logEvery === 0 || shouldClose)) {
|
|
17
|
+
this.suppressedSinceLastLog += 1;
|
|
18
|
+
return {
|
|
19
|
+
shouldClose,
|
|
20
|
+
shouldLog: false,
|
|
21
|
+
count: this.count,
|
|
22
|
+
suppressedSinceLastLog: 0
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const suppressedSinceLastLog = this.suppressedSinceLastLog;
|
|
26
|
+
this.suppressedSinceLastLog = 0;
|
|
27
|
+
return {
|
|
28
|
+
shouldClose,
|
|
29
|
+
shouldLog: true,
|
|
30
|
+
count: this.count,
|
|
31
|
+
suppressedSinceLastLog
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
reset() {
|
|
35
|
+
this.count = 0;
|
|
36
|
+
this.suppressedSinceLastLog = 0;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { UnauthorizedFloodGuard };
|
|
41
|
+
|
|
42
|
+
//# sourceMappingURL=flood-guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"flood-guard.js","names":[],"sources":["../../../../src/gateway/security/flood-guard.ts"],"sourcesContent":["/**\n * Unauthorized flood guard for WebSocket connections.\n *\n * Tracks repeated unauthorized message attempts on a single WS connection\n * and decides when to forcibly close it. Prevents a rogue client from\n * holding a socket open and spamming unauthorized requests.\n */\n\nexport type FloodGuardOptions = {\n /** Close the connection after this many unauthorized messages. @default 10 */\n closeAfter?: number;\n /** Log every N unauthorized messages to prevent log spam. @default 100 */\n logEvery?: number;\n};\n\nexport type FloodGuardDecision = {\n shouldClose: boolean;\n shouldLog: boolean;\n count: number;\n suppressedSinceLastLog: number;\n};\n\nconst DEFAULT_CLOSE_AFTER = 10;\nconst DEFAULT_LOG_EVERY = 100;\n\nexport class UnauthorizedFloodGuard {\n private readonly closeAfter: number;\n private readonly logEvery: number;\n private count = 0;\n private suppressedSinceLastLog = 0;\n\n constructor(options?: FloodGuardOptions) {\n this.closeAfter = Math.max(1, Math.floor(options?.closeAfter ?? DEFAULT_CLOSE_AFTER));\n this.logEvery = Math.max(1, Math.floor(options?.logEvery ?? DEFAULT_LOG_EVERY));\n }\n\n registerUnauthorized(): FloodGuardDecision {\n this.count += 1;\n const shouldClose = this.count > this.closeAfter;\n const shouldLog = this.count === 1 || this.count % this.logEvery === 0 || shouldClose;\n\n if (!shouldLog) {\n this.suppressedSinceLastLog += 1;\n return {\n shouldClose,\n shouldLog: false,\n count: this.count,\n suppressedSinceLastLog: 0,\n };\n }\n\n const suppressedSinceLastLog = this.suppressedSinceLastLog;\n this.suppressedSinceLastLog = 0;\n return {\n shouldClose,\n shouldLog: true,\n count: this.count,\n suppressedSinceLastLog,\n };\n }\n\n reset(): void {\n this.count = 0;\n this.suppressedSinceLastLog = 0;\n }\n}\n"],"mappings":";AAsBA,MAAM,sBAAsB;AAC5B,MAAM,oBAAoB;AAE1B,IAAa,yBAAb,MAAoC;CAClC;CACA;CACA,QAAgB;CAChB,yBAAiC;CAEjC,YAAY,SAA6B;AACvC,OAAK,aAAa,KAAK,IAAI,GAAG,KAAK,MAAM,SAAS,cAAc,oBAAoB,CAAC;AACrF,OAAK,WAAW,KAAK,IAAI,GAAG,KAAK,MAAM,SAAS,YAAY,kBAAkB,CAAC;;CAGjF,uBAA2C;AACzC,OAAK,SAAS;EACd,MAAM,cAAc,KAAK,QAAQ,KAAK;AAGtC,MAAI,EAFc,KAAK,UAAU,KAAK,KAAK,QAAQ,KAAK,aAAa,KAAK,cAE1D;AACd,QAAK,0BAA0B;AAC/B,UAAO;IACL;IACA,WAAW;IACX,OAAO,KAAK;IACZ,wBAAwB;IACzB;;EAGH,MAAM,yBAAyB,KAAK;AACpC,OAAK,yBAAyB;AAC9B,SAAO;GACL;GACA,WAAW;GACX,OAAO,KAAK;GACZ;GACD;;CAGH,QAAc;AACZ,OAAK,QAAQ;AACb,OAAK,yBAAyB"}
|