Gateway auth: accept local Tailscale Serve hostnames and tailnet IPs (#885)
* Gateway auth: accept local Tailscale Serve hostnames and tailnet IPs * fix: allow local Tailscale Serve hostnames (#885) (thanks @oswalpalash) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -42,6 +42,7 @@
|
|||||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||||
|
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||||
|
|
||||||
## 2026.1.14-1
|
## 2026.1.14-1
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,34 @@ describe("gateway auth", () => {
|
|||||||
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
|
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats local tailscale serve hostnames as direct", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: { mode: "none", allowTailscale: true },
|
||||||
|
connectAuth: null,
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "127.0.0.1" },
|
||||||
|
headers: { host: "gateway.tailnet-1234.ts.net:443" },
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.method).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat tailscale clients as direct", async () => {
|
||||||
|
const res = await authorizeGatewayConnect({
|
||||||
|
auth: { mode: "none", allowTailscale: true },
|
||||||
|
connectAuth: null,
|
||||||
|
req: {
|
||||||
|
socket: { remoteAddress: "100.64.0.42" },
|
||||||
|
headers: { host: "gateway.tailnet-1234.ts.net" },
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toBe("tailscale_user_missing");
|
||||||
|
});
|
||||||
|
|
||||||
it("allows tailscale identity to satisfy token mode auth", async () => {
|
it("allows tailscale identity to satisfy token mode auth", async () => {
|
||||||
const res = await authorizeGatewayConnect({
|
const res = await authorizeGatewayConnect({
|
||||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
import type {
|
||||||
|
GatewayAuthConfig,
|
||||||
|
GatewayTailscaleMode,
|
||||||
|
} from "../config/config.js";
|
||||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
||||||
|
|
||||||
export type ResolvedGatewayAuth = {
|
export type ResolvedGatewayAuth = {
|
||||||
@@ -42,20 +45,34 @@ function isLoopbackAddress(ip: string | undefined): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHostName(hostHeader?: string): string {
|
||||||
|
const host = (hostHeader ?? "").trim().toLowerCase();
|
||||||
|
if (!host) return "";
|
||||||
|
if (host.startsWith("[")) {
|
||||||
|
const end = host.indexOf("]");
|
||||||
|
if (end !== -1) return host.slice(1, end);
|
||||||
|
}
|
||||||
|
const [name] = host.split(":");
|
||||||
|
return name ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
||||||
if (!req) return false;
|
if (!req) return false;
|
||||||
const clientIp = req.socket?.remoteAddress ?? "";
|
const clientIp = req.socket?.remoteAddress ?? "";
|
||||||
if (!isLoopbackAddress(clientIp)) return false;
|
if (!isLoopbackAddress(clientIp)) return false;
|
||||||
|
|
||||||
const host = (req.headers.host ?? "").toLowerCase();
|
const host = getHostName(req.headers?.host);
|
||||||
const hostIsLocal =
|
const hostIsLocal =
|
||||||
host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("[::1]");
|
host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||||
|
const hostIsTailscaleServe = host.endsWith(".ts.net");
|
||||||
|
|
||||||
const hasForwarded = Boolean(
|
const hasForwarded = Boolean(
|
||||||
req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.headers["x-forwarded-host"],
|
req.headers?.["x-forwarded-for"] ||
|
||||||
|
req.headers?.["x-real-ip"] ||
|
||||||
|
req.headers?.["x-forwarded-host"],
|
||||||
);
|
);
|
||||||
|
|
||||||
return hostIsLocal && !hasForwarded;
|
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||||
@@ -64,11 +81,17 @@ function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
|||||||
if (typeof login !== "string" || !login.trim()) return null;
|
if (typeof login !== "string" || !login.trim()) return null;
|
||||||
const nameRaw = req.headers["tailscale-user-name"];
|
const nameRaw = req.headers["tailscale-user-name"];
|
||||||
const profilePic = req.headers["tailscale-user-profile-pic"];
|
const profilePic = req.headers["tailscale-user-profile-pic"];
|
||||||
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : login.trim();
|
const name =
|
||||||
|
typeof nameRaw === "string" && nameRaw.trim()
|
||||||
|
? nameRaw.trim()
|
||||||
|
: login.trim();
|
||||||
return {
|
return {
|
||||||
login: login.trim(),
|
login: login.trim(),
|
||||||
name,
|
name,
|
||||||
profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined,
|
profilePic:
|
||||||
|
typeof profilePic === "string" && profilePic.trim()
|
||||||
|
? profilePic.trim()
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,14 +99,17 @@ function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
|
|||||||
if (!req) return false;
|
if (!req) return false;
|
||||||
return Boolean(
|
return Boolean(
|
||||||
req.headers["x-forwarded-for"] &&
|
req.headers["x-forwarded-for"] &&
|
||||||
req.headers["x-forwarded-proto"] &&
|
req.headers["x-forwarded-proto"] &&
|
||||||
req.headers["x-forwarded-host"],
|
req.headers["x-forwarded-host"],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||||
if (!req) return false;
|
if (!req) return false;
|
||||||
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
|
return (
|
||||||
|
isLoopbackAddress(req.socket?.remoteAddress) &&
|
||||||
|
hasTailscaleProxyHeaders(req)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveGatewayAuth(params: {
|
export function resolveGatewayAuth(params: {
|
||||||
@@ -94,11 +120,13 @@ export function resolveGatewayAuth(params: {
|
|||||||
const authConfig = params.authConfig ?? {};
|
const authConfig = params.authConfig ?? {};
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
||||||
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
const password =
|
||||||
|
authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
||||||
const mode: ResolvedGatewayAuth["mode"] =
|
const mode: ResolvedGatewayAuth["mode"] =
|
||||||
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
|
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
|
||||||
const allowTailscale =
|
const allowTailscale =
|
||||||
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
|
authConfig.allowTailscale ??
|
||||||
|
(params.tailscaleMode === "serve" && mode !== "password");
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
token,
|
token,
|
||||||
@@ -114,7 +142,9 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (auth.mode === "password" && !auth.password) {
|
if (auth.mode === "password" && !auth.password) {
|
||||||
throw new Error("gateway auth mode is password, but no password was configured");
|
throw new Error(
|
||||||
|
"gateway auth mode is password, but no password was configured",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user