diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 6cdc645f7..1d96886fc 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { authorizeGatewayConnect, resolveGatewayAuth } from "./auth.js"; +import { + authorizeGatewayConnect, + authorizeHttpGatewayConnect, + authorizeWsControlUiGatewayConnect, + resolveGatewayAuth, +} from "./auth.js"; function createLimiterSpy(): AuthRateLimiter & { check: ReturnType; @@ -215,7 +220,7 @@ describe("gateway auth", () => { auth: { mode: "token", token: "secret", allowTailscale: true }, connectAuth: null, tailscaleWhois: async () => ({ login: "peter", name: "Peter" }), - allowTailscaleHeaderAuth: true, + authSurface: "ws-control-ui", req: { socket: { remoteAddress: "127.0.0.1" }, headers: { @@ -234,6 +239,49 @@ describe("gateway auth", () => { expect(res.user).toBe("peter"); }); + it("keeps tailscale header auth disabled on HTTP auth wrapper", async () => { + const res = await authorizeHttpGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: true }, + connectAuth: null, + tailscaleWhois: async () => ({ login: "peter", name: "Peter" }), + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-for": "100.64.0.1", + "x-forwarded-proto": "https", + "x-forwarded-host": "ai-hub.bone-egret.ts.net", + "tailscale-user-login": "peter", + "tailscale-user-name": "Peter", + }, + } as never, + }); + expect(res.ok).toBe(false); + expect(res.reason).toBe("token_missing"); + }); + + it("enables tailscale header auth on ws control-ui auth wrapper", async () => { + const res = await authorizeWsControlUiGatewayConnect({ + auth: { mode: "token", token: "secret", allowTailscale: true }, + connectAuth: null, + tailscaleWhois: async () => ({ login: "peter", name: "Peter" }), + req: { + socket: { remoteAddress: "127.0.0.1" }, + headers: { + host: "gateway.local", + "x-forwarded-for": "100.64.0.1", + "x-forwarded-proto": "https", + "x-forwarded-host": "ai-hub.bone-egret.ts.net", + "tailscale-user-login": "peter", + "tailscale-user-name": "Peter", + }, + } as never, + }); + expect(res.ok).toBe(true); + expect(res.method).toBe("tailscale"); + expect(res.user).toBe("peter"); + }); + it("uses proxy-aware request client IP by default for rate-limit checks", async () => { const limiter = createLimiterSpy(); const res = await authorizeGatewayConnect({ diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 76236dc2e..14c05a817 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -52,6 +52,27 @@ type ConnectAuth = { password?: string; }; +export type GatewayAuthSurface = "http" | "ws-control-ui"; + +export type AuthorizeGatewayConnectParams = { + auth: ResolvedGatewayAuth; + connectAuth?: ConnectAuth | null; + req?: IncomingMessage; + trustedProxies?: string[]; + tailscaleWhois?: TailscaleWhoisLookup; + /** + * Explicit auth surface. HTTP keeps Tailscale forwarded-header auth disabled. + * WS Control UI enables it intentionally for tokenless trusted-host login. + */ + authSurface?: GatewayAuthSurface; + /** Optional rate limiter instance; when provided, failed attempts are tracked per IP. */ + rateLimiter?: AuthRateLimiter; + /** Client IP used for rate-limit tracking. Falls back to proxy-aware request IP resolution. */ + clientIp?: string; + /** Optional limiter scope; defaults to shared-secret auth scope. */ + rateLimitScope?: string; +}; + type TailscaleUser = { login: string; name: string; @@ -319,27 +340,17 @@ function authorizeTrustedProxy(params: { return { user }; } -export async function authorizeGatewayConnect(params: { - auth: ResolvedGatewayAuth; - connectAuth?: ConnectAuth | null; - req?: IncomingMessage; - trustedProxies?: string[]; - tailscaleWhois?: TailscaleWhoisLookup; - /** - * Opt-in for accepting Tailscale Serve identity headers as primary auth. - * Default is disabled for HTTP surfaces; WS connect enables this explicitly. - */ - allowTailscaleHeaderAuth?: boolean; - /** Optional rate limiter instance; when provided, failed attempts are tracked per IP. */ - rateLimiter?: AuthRateLimiter; - /** Client IP used for rate-limit tracking. Falls back to proxy-aware request IP resolution. */ - clientIp?: string; - /** Optional limiter scope; defaults to shared-secret auth scope. */ - rateLimitScope?: string; -}): Promise { +function shouldAllowTailscaleHeaderAuth(authSurface: GatewayAuthSurface): boolean { + return authSurface === "ws-control-ui"; +} + +export async function authorizeGatewayConnect( + params: AuthorizeGatewayConnectParams, +): Promise { const { auth, connectAuth, req, trustedProxies } = params; const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; - const allowTailscaleHeaderAuth = params.allowTailscaleHeaderAuth === true; + const authSurface = params.authSurface ?? "http"; + const allowTailscaleHeaderAuth = shouldAllowTailscaleHeaderAuth(authSurface); const localDirect = isLocalDirectRequest(req, trustedProxies); if (auth.mode === "trusted-proxy") { @@ -433,3 +444,21 @@ export async function authorizeGatewayConnect(params: { limiter?.recordFailure(ip, rateLimitScope); return { ok: false, reason: "unauthorized" }; } + +export async function authorizeHttpGatewayConnect( + params: Omit, +): Promise { + return authorizeGatewayConnect({ + ...params, + authSurface: "http", + }); +} + +export async function authorizeWsControlUiGatewayConnect( + params: Omit, +): Promise { + return authorizeGatewayConnect({ + ...params, + authSurface: "ws-control-ui", + }); +} diff --git a/src/gateway/http-auth-helpers.test.ts b/src/gateway/http-auth-helpers.test.ts index 22ceb975d..aa3d83d5b 100644 --- a/src/gateway/http-auth-helpers.test.ts +++ b/src/gateway/http-auth-helpers.test.ts @@ -4,7 +4,7 @@ import type { ResolvedGatewayAuth } from "./auth.js"; import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; vi.mock("./auth.js", () => ({ - authorizeGatewayConnect: vi.fn(), + authorizeHttpGatewayConnect: vi.fn(), })); vi.mock("./http-common.js", () => ({ @@ -15,7 +15,7 @@ vi.mock("./http-utils.js", () => ({ getBearerToken: vi.fn(), })); -const { authorizeGatewayConnect } = await import("./auth.js"); +const { authorizeHttpGatewayConnect } = await import("./auth.js"); const { sendGatewayAuthFailure } = await import("./http-common.js"); const { getBearerToken } = await import("./http-utils.js"); @@ -26,7 +26,7 @@ describe("authorizeGatewayBearerRequestOrReply", () => { it("disables tailscale header auth for HTTP bearer checks", async () => { vi.mocked(getBearerToken).mockReturnValue(undefined); - vi.mocked(authorizeGatewayConnect).mockResolvedValue({ + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: false, reason: "token_missing", }); @@ -43,9 +43,8 @@ describe("authorizeGatewayBearerRequestOrReply", () => { }); expect(ok).toBe(false); - expect(vi.mocked(authorizeGatewayConnect)).toHaveBeenCalledWith( + expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith( expect.objectContaining({ - allowTailscaleHeaderAuth: false, connectAuth: null, }), ); @@ -54,7 +53,7 @@ describe("authorizeGatewayBearerRequestOrReply", () => { it("forwards bearer token and returns true on successful auth", async () => { vi.mocked(getBearerToken).mockReturnValue("abc"); - vi.mocked(authorizeGatewayConnect).mockResolvedValue({ ok: true, method: "token" }); + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: true, method: "token" }); const ok = await authorizeGatewayBearerRequestOrReply({ req: {} as IncomingMessage, @@ -68,9 +67,8 @@ describe("authorizeGatewayBearerRequestOrReply", () => { }); expect(ok).toBe(true); - expect(vi.mocked(authorizeGatewayConnect)).toHaveBeenCalledWith( + expect(vi.mocked(authorizeHttpGatewayConnect)).toHaveBeenCalledWith( expect.objectContaining({ - allowTailscaleHeaderAuth: false, connectAuth: { token: "abc", password: "abc" }, }), ); diff --git a/src/gateway/http-auth-helpers.ts b/src/gateway/http-auth-helpers.ts index a7ee69eb6..36edb7c8d 100644 --- a/src/gateway/http-auth-helpers.ts +++ b/src/gateway/http-auth-helpers.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { sendGatewayAuthFailure } from "./http-common.js"; import { getBearerToken } from "./http-utils.js"; @@ -12,12 +12,11 @@ export async function authorizeGatewayBearerRequestOrReply(params: { rateLimiter?: AuthRateLimiter; }): Promise { const token = getBearerToken(params.req); - const authResult = await authorizeGatewayConnect({ + const authResult = await authorizeHttpGatewayConnect({ auth: params.auth, connectAuth: token ? { token, password: token } : null, req: params.req, trustedProxies: params.trustedProxies, - allowTailscaleHeaderAuth: false, rateLimiter: params.rateLimiter, }); if (!authResult.ok) { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 6cabb0fb4..c1c29b355 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -21,7 +21,7 @@ import { safeEqualSecret } from "../security/secret-equal.js"; import { handleSlackHttpRequest } from "../slack/http/index.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { - authorizeGatewayConnect, + authorizeHttpGatewayConnect, isLocalDirectRequest, type GatewayAuthResult, type ResolvedGatewayAuth, @@ -150,12 +150,11 @@ async function authorizeCanvasRequest(params: { let lastAuthFailure: GatewayAuthResult | null = null; const token = getBearerToken(req); if (token) { - const authResult = await authorizeGatewayConnect({ + const authResult = await authorizeHttpGatewayConnect({ auth: { ...auth, allowTailscale: false }, connectAuth: { token, password: token }, req, trustedProxies, - allowTailscaleHeaderAuth: false, rateLimiter, }); if (authResult.ok) { @@ -528,12 +527,11 @@ export function createGatewayHttpServer(opts: { // their own auth when exposing sensitive functionality. if (requestPath.startsWith("/api/channels/")) { const token = getBearerToken(req); - const authResult = await authorizeGatewayConnect({ + const authResult = await authorizeHttpGatewayConnect({ auth: resolvedAuth, connectAuth: token ? { token, password: token } : null, req, trustedProxies, - allowTailscaleHeaderAuth: false, rateLimiter, }); if (!authResult.ok) { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 92a3d1e5c..1be8b5778 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -30,7 +30,11 @@ import { type AuthRateLimiter, } from "../../auth-rate-limit.js"; import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; -import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js"; +import { + authorizeHttpGatewayConnect, + authorizeWsControlUiGatewayConnect, + isLocalDirectRequest, +} from "../../auth.js"; import { buildCanvasScopedHostUrl, CANVAS_CAPABILITY_TTL_MS, @@ -380,12 +384,11 @@ export function attachGatewayWsMessageHandler(params: { const resolveAuthState = async () => { const hasDeviceTokenCandidate = Boolean(connectParams.auth?.token && device); - let nextAuthResult: GatewayAuthResult = await authorizeGatewayConnect({ + let nextAuthResult: GatewayAuthResult = await authorizeWsControlUiGatewayConnect({ auth: resolvedAuth, connectAuth: connectParams.auth, req: upgradeReq, trustedProxies, - allowTailscaleHeaderAuth: true, rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter, clientIp, rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, @@ -416,7 +419,7 @@ export function attachGatewayWsMessageHandler(params: { const nextAuthMethod = nextAuthResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token"); const sharedAuthResult = hasSharedAuth - ? await authorizeGatewayConnect({ + ? await authorizeHttpGatewayConnect({ auth: { ...resolvedAuth, allowTailscale: false }, connectAuth: connectParams.auth, req: upgradeReq, diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index eea4ebb8b..2a631f9c3 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -34,7 +34,7 @@ vi.mock("../config/sessions.js", () => ({ })); vi.mock("./auth.js", () => ({ - authorizeGatewayConnect: async () => ({ ok: true }), + authorizeHttpGatewayConnect: async () => ({ ok: true }), })); vi.mock("../logger.js", () => ({ diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index eda5b8164..84f14457f 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -24,7 +24,7 @@ import { isSubagentSessionKey } from "../routing/session-key.js"; import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; -import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; +import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { readJsonBodyOrError, sendGatewayAuthFailure, @@ -146,12 +146,11 @@ export async function handleToolsInvokeHttpRequest( const cfg = loadConfig(); const token = getBearerToken(req); - const authResult = await authorizeGatewayConnect({ + const authResult = await authorizeHttpGatewayConnect({ auth: opts.auth, connectAuth: token ? { token, password: token } : null, req, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, - allowTailscaleHeaderAuth: false, rateLimiter: opts.rateLimiter, }); if (!authResult.ok) {