fix(gateway): require shared auth before device bypass

This commit is contained in:
Peter Steinberger
2026-02-02 16:55:53 -08:00
parent d1ecb46076
commit fe81b1d712
5 changed files with 131 additions and 44 deletions

View File

@@ -377,8 +377,63 @@ export function attachGatewayWsMessageHandler(params: {
isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true;
const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth;
const device = disableControlUiDeviceAuth ? null : deviceRaw;
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
});
let authOk = authResult.ok;
let authMethod =
authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
const sharedAuthResult = hasSharedAuth
? await authorizeGatewayConnect({
auth: { ...resolvedAuth, allowTailscale: false },
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
})
: null;
const sharedAuthOk =
sharedAuthResult?.ok === true &&
(sharedAuthResult.method === "token" || sharedAuthResult.method === "password");
const rejectUnauthorized = () => {
setHandshakeState("failed");
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`,
);
const authProvided: AuthProvidedKind = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,
reason: authResult.reason,
client: connectParams.client,
});
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, authMessage),
});
close(1008, truncateCloseReason(authMessage));
};
if (!device) {
const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth;
const canSkipDevice = sharedAuthOk;
if (isControlUi && !allowControlUiBypass) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
@@ -399,8 +454,12 @@ export function attachGatewayWsMessageHandler(params: {
return;
}
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
// Allow shared-secret authenticated connections (e.g., control-ui) to skip device identity
if (!canSkipDevice) {
if (!authOk && hasSharedAuth) {
rejectUnauthorized();
return;
}
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
@@ -567,15 +626,6 @@ export function attachGatewayWsMessageHandler(params: {
}
}
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: connectParams.auth,
req: upgradeReq,
trustedProxies,
});
let authOk = authResult.ok;
let authMethod =
authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token");
if (!authOk && connectParams.auth?.token && device) {
const tokenCheck = await verifyDeviceToken({
deviceId: device.id,
@@ -589,42 +639,11 @@ export function attachGatewayWsMessageHandler(params: {
}
}
if (!authOk) {
setHandshakeState("failed");
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`,
);
const authProvided: AuthProvidedKind = connectParams.auth?.token
? "token"
: connectParams.auth?.password
? "password"
: "none";
const authMessage = formatGatewayAuthFailureMessage({
authMode: resolvedAuth.mode,
authProvided,
reason: authResult.reason,
client: connectParams.client,
});
setCloseCause("unauthorized", {
authMode: resolvedAuth.mode,
authProvided,
authReason: authResult.reason,
allowTailscale: resolvedAuth.allowTailscale,
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, authMessage),
});
close(1008, truncateCloseReason(authMessage));
rejectUnauthorized();
return;
}
const skipPairing = allowControlUiBypass && hasSharedAuth;
const skipPairing = allowControlUiBypass && sharedAuthOk;
if (device && devicePublicKey && !skipPairing) {
const requirePairing = async (reason: string, _paired?: { deviceId: string }) => {
const pairing = await requestDevicePairing({