fix: harden gateway password auth

This commit is contained in:
Peter Steinberger
2026-01-02 16:47:52 +01:00
parent fe87d6d8be
commit a8bc974a2e
5 changed files with 47 additions and 20 deletions

View File

@@ -26,15 +26,24 @@ actor GatewayEndpointStore {
mode: { await MainActor.run { AppStateStore.shared.connectionMode } }, mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] }, token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
password: { password: {
// First check environment variable
let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? "" let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { if !trimmed.isEmpty {
return trimmed return trimmed
} }
// Then check config file based on connection mode
let root = ClawdisConfigFile.loadDict() let root = ClawdisConfigFile.loadDict()
// Check gateway.auth.password (for local gateway auth) if CommandResolver.connectionModeIsRemote() {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let password = remote["password"] as? String
{
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
if !pw.isEmpty {
return pw
}
}
return nil
}
if let gateway = root["gateway"] as? [String: Any], if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any], let auth = gateway["auth"] as? [String: Any],
let password = auth["password"] as? String let password = auth["password"] as? String
@@ -44,16 +53,6 @@ actor GatewayEndpointStore {
return pw return pw
} }
} }
// Check gateway.remote.password (for remote gateway auth)
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let password = remote["password"] as? String
{
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
if !pw.isEmpty {
return pw
}
}
return nil return nil
}, },
localPort: { GatewayEnvironment.gatewayPort() }, localPort: { GatewayEnvironment.gatewayPort() },

View File

@@ -72,15 +72,17 @@ enum GatewayLaunchAgentManager {
<string>sips</string> <string>sips</string>
""" """
if let token { if let token {
let escapedToken = self.escapePlistValue(token)
envEntries += """ envEntries += """
<key>CLAWDIS_GATEWAY_TOKEN</key> <key>CLAWDIS_GATEWAY_TOKEN</key>
<string>\(token)</string> <string>\(escapedToken)</string>
""" """
} }
if let password { if let password {
let escapedPassword = self.escapePlistValue(password)
envEntries += """ envEntries += """
<key>CLAWDIS_GATEWAY_PASSWORD</key> <key>CLAWDIS_GATEWAY_PASSWORD</key>
<string>\(password)</string> <string>\(escapedPassword)</string>
""" """
} }
let plist = """ let plist = """
@@ -171,6 +173,15 @@ enum GatewayLaunchAgentManager {
return nil return nil
} }
private static func escapePlistValue(_ raw: String) -> String {
raw
.replacingOccurrences(of: "&", with: "&amp;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "'", with: "&apos;")
}
private struct LaunchctlResult { private struct LaunchctlResult {
let status: Int32 let status: Int32
let output: String let output: String

View File

@@ -29,6 +29,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init( let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() }, mode: { mode.get() },
token: { "t" }, token: { "t" },
password: { nil },
localPort: { 1234 }, localPort: { 1234 },
remotePortIfRunning: { nil }, remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 })) ensureRemoteTunnel: { 18789 }))
@@ -44,6 +45,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init( let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() }, mode: { mode.get() },
token: { nil }, token: { nil },
password: { nil },
localPort: { 18789 }, localPort: { 18789 },
remotePortIfRunning: { nil }, remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 })) ensureRemoteTunnel: { 18789 }))
@@ -58,6 +60,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init( let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() }, mode: { mode.get() },
token: { "tok" }, token: { "tok" },
password: { "pw" },
localPort: { 1 }, localPort: { 1 },
remotePortIfRunning: { 5555 }, remotePortIfRunning: { 5555 },
ensureRemoteTunnel: { 5555 })) ensureRemoteTunnel: { 5555 }))
@@ -69,13 +72,14 @@ import Testing
_ = try await store.ensureRemoteControlTunnel() _ = try await store.ensureRemoteControlTunnel()
let next = await iterator.next() let next = await iterator.next()
guard case let .ready(mode, url, token) = next else { guard case let .ready(mode, url, token, password) = next else {
Issue.record("expected .ready after ensure, got \(String(describing: next))") Issue.record("expected .ready after ensure, got \(String(describing: next))")
return return
} }
#expect(mode == .remote) #expect(mode == .remote)
#expect(url.absoluteString == "ws://127.0.0.1:5555") #expect(url.absoluteString == "ws://127.0.0.1:5555")
#expect(token == "tok") #expect(token == "tok")
#expect(password == "pw")
} }
@Test func unconfiguredModeRejectsConfig() async { @Test func unconfiguredModeRejectsConfig() async {
@@ -83,6 +87,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init( let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() }, mode: { mode.get() },
token: { nil }, token: { nil },
password: { nil },
localPort: { 18789 }, localPort: { 18789 },
remotePortIfRunning: { nil }, remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 })) ensureRemoteTunnel: { 18789 }))

View File

@@ -46,7 +46,7 @@ export async function callGateway<T = unknown>(
(typeof opts.password === "string" && opts.password.trim().length > 0 (typeof opts.password === "string" && opts.password.trim().length > 0
? opts.password.trim() ? opts.password.trim()
: undefined) || : undefined) ||
(process.env.CLAWDIS_GATEWAY_PASSWORD?.trim()) || process.env.CLAWDIS_GATEWAY_PASSWORD?.trim() ||
(typeof remote?.password === "string" && remote.password.trim().length > 0 (typeof remote?.password === "string" && remote.password.trim().length > 0
? remote.password.trim() ? remote.password.trim()
: undefined); : undefined);

View File

@@ -18,7 +18,13 @@ export type HookMappingResolved = {
messageTemplate?: string; messageTemplate?: string;
textTemplate?: string; textTemplate?: string;
deliver?: boolean; deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; channel?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "signal"
| "imessage";
to?: string; to?: string;
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
@@ -50,7 +56,13 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey?: string; sessionKey?: string;
deliver?: boolean; deliver?: boolean;
channel?: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage"; channel?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "signal"
| "imessage";
to?: string; to?: string;
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
@@ -86,7 +98,7 @@ type HookTransformResult = Partial<{
name: string; name: string;
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
channel: "last" | "whatsapp" | "telegram" | "discord"; channel: "last" | "whatsapp" | "telegram" | "discord" | "signal" | "imessage";
to: string; to: string;
thinking: string; thinking: string;
timeoutSeconds: number; timeoutSeconds: number;