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 } },
token: { ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] },
password: {
// First check environment variable
let raw = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
// Then check config file based on connection mode
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],
let auth = gateway["auth"] as? [String: Any],
let password = auth["password"] as? String
@@ -44,16 +53,6 @@ actor GatewayEndpointStore {
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
},
localPort: { GatewayEnvironment.gatewayPort() },

View File

@@ -72,15 +72,17 @@ enum GatewayLaunchAgentManager {
<string>sips</string>
"""
if let token {
let escapedToken = self.escapePlistValue(token)
envEntries += """
<key>CLAWDIS_GATEWAY_TOKEN</key>
<string>\(token)</string>
<string>\(escapedToken)</string>
"""
}
if let password {
let escapedPassword = self.escapePlistValue(password)
envEntries += """
<key>CLAWDIS_GATEWAY_PASSWORD</key>
<string>\(password)</string>
<string>\(escapedPassword)</string>
"""
}
let plist = """
@@ -171,6 +173,15 @@ enum GatewayLaunchAgentManager {
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 {
let status: Int32
let output: String

View File

@@ -29,6 +29,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { "t" },
password: { nil },
localPort: { 1234 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
@@ -44,6 +45,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { nil },
password: { nil },
localPort: { 18789 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))
@@ -58,6 +60,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { "tok" },
password: { "pw" },
localPort: { 1 },
remotePortIfRunning: { 5555 },
ensureRemoteTunnel: { 5555 }))
@@ -69,13 +72,14 @@ import Testing
_ = try await store.ensureRemoteControlTunnel()
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))")
return
}
#expect(mode == .remote)
#expect(url.absoluteString == "ws://127.0.0.1:5555")
#expect(token == "tok")
#expect(password == "pw")
}
@Test func unconfiguredModeRejectsConfig() async {
@@ -83,6 +87,7 @@ import Testing
let store = GatewayEndpointStore(deps: .init(
mode: { mode.get() },
token: { nil },
password: { nil },
localPort: { 18789 },
remotePortIfRunning: { nil },
ensureRemoteTunnel: { 18789 }))

View File

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

View File

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