mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 23:01:24 +00:00
fix(gateway): scope tailscale tokenless auth to websocket
This commit is contained in:
@@ -188,7 +188,7 @@ describe("gateway auth", () => {
|
||||
expect(res.method).toBe("token");
|
||||
});
|
||||
|
||||
it("allows tailscale identity to satisfy token mode auth", async () => {
|
||||
it("does not allow tailscale identity to satisfy token mode auth by default", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
@@ -206,6 +206,29 @@ describe("gateway auth", () => {
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("token_missing");
|
||||
});
|
||||
|
||||
it("allows tailscale identity when header auth is explicitly enabled", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
|
||||
allowTailscaleHeaderAuth: true,
|
||||
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");
|
||||
|
||||
@@ -325,6 +325,11 @@ export async function authorizeGatewayConnect(params: {
|
||||
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. */
|
||||
@@ -334,6 +339,7 @@ export async function authorizeGatewayConnect(params: {
|
||||
}): Promise<GatewayAuthResult> {
|
||||
const { auth, connectAuth, req, trustedProxies } = params;
|
||||
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
|
||||
const allowTailscaleHeaderAuth = params.allowTailscaleHeaderAuth === true;
|
||||
const localDirect = isLocalDirectRequest(req, trustedProxies);
|
||||
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
@@ -376,7 +382,7 @@ export async function authorizeGatewayConnect(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.allowTailscale && !localDirect) {
|
||||
if (allowTailscaleHeaderAuth && auth.allowTailscale && !localDirect) {
|
||||
const tailscaleCheck = await resolveVerifiedTailscaleUser({
|
||||
req,
|
||||
tailscaleWhois,
|
||||
|
||||
79
src/gateway/http-auth-helpers.test.ts
Normal file
79
src/gateway/http-auth-helpers.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js";
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
authorizeGatewayConnect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./http-common.js", () => ({
|
||||
sendGatewayAuthFailure: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./http-utils.js", () => ({
|
||||
getBearerToken: vi.fn(),
|
||||
}));
|
||||
|
||||
const { authorizeGatewayConnect } = await import("./auth.js");
|
||||
const { sendGatewayAuthFailure } = await import("./http-common.js");
|
||||
const { getBearerToken } = await import("./http-utils.js");
|
||||
|
||||
describe("authorizeGatewayBearerRequestOrReply", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("disables tailscale header auth for HTTP bearer checks", async () => {
|
||||
vi.mocked(getBearerToken).mockReturnValue(null);
|
||||
vi.mocked(authorizeGatewayConnect).mockResolvedValue({
|
||||
ok: false,
|
||||
reason: "token_missing",
|
||||
});
|
||||
|
||||
const ok = await authorizeGatewayBearerRequestOrReply({
|
||||
req: {} as IncomingMessage,
|
||||
res: {} as ServerResponse,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
password: undefined,
|
||||
allowTailscale: true,
|
||||
} satisfies ResolvedGatewayAuth,
|
||||
});
|
||||
|
||||
expect(ok).toBe(false);
|
||||
expect(vi.mocked(authorizeGatewayConnect)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowTailscaleHeaderAuth: false,
|
||||
connectAuth: null,
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(sendGatewayAuthFailure)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards bearer token and returns true on successful auth", async () => {
|
||||
vi.mocked(getBearerToken).mockReturnValue("abc");
|
||||
vi.mocked(authorizeGatewayConnect).mockResolvedValue({ ok: true, method: "token" });
|
||||
|
||||
const ok = await authorizeGatewayBearerRequestOrReply({
|
||||
req: {} as IncomingMessage,
|
||||
res: {} as ServerResponse,
|
||||
auth: {
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
password: undefined,
|
||||
allowTailscale: true,
|
||||
} satisfies ResolvedGatewayAuth,
|
||||
});
|
||||
|
||||
expect(ok).toBe(true);
|
||||
expect(vi.mocked(authorizeGatewayConnect)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowTailscaleHeaderAuth: false,
|
||||
connectAuth: { token: "abc", password: "abc" },
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(sendGatewayAuthFailure)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
|
||||
connectAuth: token ? { token, password: token } : null,
|
||||
req: params.req,
|
||||
trustedProxies: params.trustedProxies,
|
||||
allowTailscaleHeaderAuth: false,
|
||||
rateLimiter: params.rateLimiter,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
|
||||
@@ -155,6 +155,7 @@ async function authorizeCanvasRequest(params: {
|
||||
connectAuth: { token, password: token },
|
||||
req,
|
||||
trustedProxies,
|
||||
allowTailscaleHeaderAuth: false,
|
||||
rateLimiter,
|
||||
});
|
||||
if (authResult.ok) {
|
||||
@@ -532,6 +533,7 @@ export function createGatewayHttpServer(opts: {
|
||||
connectAuth: token ? { token, password: token } : null,
|
||||
req,
|
||||
trustedProxies,
|
||||
allowTailscaleHeaderAuth: false,
|
||||
rateLimiter,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
|
||||
@@ -351,6 +351,7 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
connectAuth: connectParams.auth,
|
||||
req: upgradeReq,
|
||||
trustedProxies,
|
||||
allowTailscaleHeaderAuth: true,
|
||||
rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter,
|
||||
clientIp,
|
||||
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,
|
||||
|
||||
@@ -151,6 +151,7 @@ export async function handleToolsInvokeHttpRequest(
|
||||
connectAuth: token ? { token, password: token } : null,
|
||||
req,
|
||||
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
|
||||
allowTailscaleHeaderAuth: false,
|
||||
rateLimiter: opts.rateLimiter,
|
||||
});
|
||||
if (!authResult.ok) {
|
||||
|
||||
Reference in New Issue
Block a user