fix(gateway): avoid crash in handshake auth
This commit is contained in:
200
src/gateway/auth.ts
Normal file
200
src/gateway/auth.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import os from "node:os";
|
||||
|
||||
import { type PamAvailability, verifyPamCredentials } from "../infra/pam.js";
|
||||
|
||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "system";
|
||||
|
||||
export type ResolvedGatewayAuth = {
|
||||
mode: ResolvedGatewayAuthMode;
|
||||
token?: string;
|
||||
password?: string;
|
||||
username?: string;
|
||||
allowTailscale: boolean;
|
||||
};
|
||||
|
||||
export type GatewayAuthResult = {
|
||||
ok: boolean;
|
||||
method?: "none" | "token" | "password" | "system" | "tailscale";
|
||||
user?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
type ConnectAuth = {
|
||||
token?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
type TailscaleUser = {
|
||||
login: string;
|
||||
name: string;
|
||||
profilePic?: string;
|
||||
};
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
|
||||
function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
if (!ip) return false;
|
||||
if (ip === "127.0.0.1") return true;
|
||||
if (ip.startsWith("127.")) return true;
|
||||
if (ip === "::1") return true;
|
||||
if (ip.startsWith("::ffff:127.")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
const clientIp = req.socket?.remoteAddress ?? "";
|
||||
if (!isLoopbackAddress(clientIp)) return false;
|
||||
|
||||
const host = (req.headers.host ?? "").toLowerCase();
|
||||
const hostIsLocal =
|
||||
host.startsWith("localhost") ||
|
||||
host.startsWith("127.0.0.1") ||
|
||||
host.startsWith("[::1]");
|
||||
|
||||
const hasForwarded = Boolean(
|
||||
req.headers["x-forwarded-for"] ||
|
||||
req.headers["x-real-ip"] ||
|
||||
req.headers["x-forwarded-host"],
|
||||
);
|
||||
|
||||
return hostIsLocal && !hasForwarded;
|
||||
}
|
||||
|
||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
if (!req) return null;
|
||||
const login = req.headers["tailscale-user-login"];
|
||||
if (typeof login !== "string" || !login.trim()) return null;
|
||||
const nameRaw = req.headers["tailscale-user-name"];
|
||||
const profilePic = req.headers["tailscale-user-profile-pic"];
|
||||
const name =
|
||||
typeof nameRaw === "string" && nameRaw.trim()
|
||||
? nameRaw.trim()
|
||||
: login.trim();
|
||||
return {
|
||||
login: login.trim(),
|
||||
name,
|
||||
profilePic:
|
||||
typeof profilePic === "string" && profilePic.trim()
|
||||
? profilePic.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
return Boolean(
|
||||
req.headers["x-forwarded-for"] &&
|
||||
req.headers["x-forwarded-proto"] &&
|
||||
req.headers["x-forwarded-host"],
|
||||
);
|
||||
}
|
||||
|
||||
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
return (
|
||||
isLoopbackAddress(req.socket?.remoteAddress) &&
|
||||
hasTailscaleProxyHeaders(req)
|
||||
);
|
||||
}
|
||||
|
||||
export function assertGatewayAuthConfigured(
|
||||
auth: ResolvedGatewayAuth,
|
||||
pam: PamAvailability,
|
||||
): void {
|
||||
if (auth.mode === "token" && !auth.token) {
|
||||
throw new Error(
|
||||
"gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set",
|
||||
);
|
||||
}
|
||||
if (auth.mode === "password" && !auth.password) {
|
||||
throw new Error(
|
||||
"gateway auth mode is password, but no password was configured",
|
||||
);
|
||||
}
|
||||
if (auth.mode === "system" && !pam.available) {
|
||||
throw new Error(
|
||||
`gateway auth mode is system, but PAM auth is unavailable${
|
||||
pam.error ? `: ${pam.error}` : ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function authorizeGatewayConnect(params: {
|
||||
auth: ResolvedGatewayAuth;
|
||||
connectAuth?: ConnectAuth | null;
|
||||
req?: IncomingMessage;
|
||||
}): Promise<GatewayAuthResult> {
|
||||
const { auth, connectAuth, req } = params;
|
||||
const localDirect = isLocalDirectRequest(req);
|
||||
|
||||
if (auth.mode === "none") {
|
||||
if (auth.allowTailscale && !localDirect) {
|
||||
const tailscaleUser = getTailscaleUser(req);
|
||||
if (!tailscaleUser) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
}
|
||||
if (!isTailscaleProxyRequest(req)) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
method: "tailscale",
|
||||
user: tailscaleUser.login,
|
||||
};
|
||||
}
|
||||
return { ok: true, method: "none" };
|
||||
}
|
||||
|
||||
if (auth.mode === "token") {
|
||||
if (auth.token && connectAuth?.token === auth.token) {
|
||||
return { ok: true, method: "token" };
|
||||
}
|
||||
}
|
||||
|
||||
if (auth.mode === "password") {
|
||||
const password = connectAuth?.password;
|
||||
if (!password || !auth.password) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
}
|
||||
if (!safeEqual(password, auth.password)) {
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
}
|
||||
return { ok: true, method: "password" };
|
||||
}
|
||||
|
||||
if (auth.mode === "system") {
|
||||
const password = connectAuth?.password;
|
||||
if (!password) return { ok: false, reason: "unauthorized" };
|
||||
const username = (
|
||||
connectAuth?.username ??
|
||||
auth.username ??
|
||||
os.userInfo().username
|
||||
).trim();
|
||||
if (!username) return { ok: false, reason: "unauthorized" };
|
||||
const ok = await verifyPamCredentials(username, password);
|
||||
return ok
|
||||
? { ok: true, method: "system", user: username }
|
||||
: { ok: false, reason: "unauthorized" };
|
||||
}
|
||||
|
||||
if (auth.allowTailscale) {
|
||||
const tailscaleUser = getTailscaleUser(req);
|
||||
if (tailscaleUser && isTailscaleProxyRequest(req)) {
|
||||
return {
|
||||
ok: true,
|
||||
method: "tailscale",
|
||||
user: tailscaleUser.login,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: false, reason: "unauthorized" };
|
||||
}
|
||||
Reference in New Issue
Block a user