Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
5ab3632d2c fix(auth): support non-localhost CLI callback for self-hosted VMs
The CLI auth callback was hardcoded to localhost, breaking self-hosted
setups where the browser runs on a different machine than the CLI.

- CLI: derive callback host from configured app URL; bind to 0.0.0.0
  when the app URL is not localhost so remote browsers can reach it
- Frontend: expand validateCliCallback to accept RFC 1918 private IPs
  (10.x, 172.16-31.x, 192.168.x) in addition to localhost

Closes #923
2026-04-14 13:37:08 +08:00
3 changed files with 50 additions and 7 deletions

View File

@@ -726,12 +726,34 @@ describe("validateCliCallback", () => {
expect(validateCliCallback("http://127.0.0.1:8080/cb")).toBe(true);
});
it("accepts 10.x.x.x private IPs", () => {
expect(validateCliCallback("http://10.0.0.5:9876/callback")).toBe(true);
expect(validateCliCallback("http://10.255.255.255:1234/cb")).toBe(true);
});
it("accepts 172.16-31.x.x private IPs", () => {
expect(validateCliCallback("http://172.16.0.1:9876/callback")).toBe(true);
expect(validateCliCallback("http://172.31.255.255:1234/cb")).toBe(true);
});
it("rejects 172.x outside 16-31 range", () => {
expect(validateCliCallback("http://172.15.0.1:9876/callback")).toBe(false);
expect(validateCliCallback("http://172.32.0.1:9876/callback")).toBe(false);
});
it("accepts 192.168.x.x private IPs", () => {
expect(validateCliCallback("http://192.168.1.131:41117/callback")).toBe(true);
expect(validateCliCallback("http://192.168.0.1:8080/cb")).toBe(true);
});
it("rejects https:// URLs", () => {
expect(validateCliCallback("https://localhost:9876/callback")).toBe(false);
});
it("rejects non-localhost hosts", () => {
it("rejects public IPs and domains", () => {
expect(validateCliCallback("http://evil.com:9876/callback")).toBe(false);
expect(validateCliCallback("http://8.8.8.8:9876/callback")).toBe(false);
expect(validateCliCallback("http://192.169.1.1:9876/callback")).toBe(false);
});
it("rejects invalid URLs", () => {

View File

@@ -68,14 +68,22 @@ function redirectToCliCallback(url: string, token: string, state: string) {
window.location.href = `${url}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`;
}
/** Validate that a CLI callback URL points to localhost over HTTP. */
/**
* Validate that a CLI callback URL points to a safe host over HTTP.
* Allows localhost and private/LAN IPs (RFC 1918) to support self-hosted setups
* on local VMs while blocking arbitrary public hosts.
*/
export function validateCliCallback(cliCallback: string): boolean {
try {
const cbUrl = new URL(cliCallback);
if (cbUrl.protocol !== "http:") return false;
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
return false;
return true;
const h = cbUrl.hostname;
if (h === "localhost" || h === "127.0.0.1") return true;
// Allow RFC 1918 private IPs: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
if (/^10\./.test(h)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
return false;
} catch {
return false;
}

View File

@@ -98,15 +98,28 @@ func runAuthLoginBrowser(cmd *cobra.Command) error {
serverURL := resolveServerURL(cmd)
appURL := resolveAppURL(cmd)
// Determine the callback host from the configured app URL.
// For self-hosted setups where the browser is on a different machine,
// we need to use the server's reachable hostname instead of localhost.
callbackHost := "localhost"
bindAddr := "127.0.0.1"
if parsed, err := url.Parse(appURL); err == nil {
h := parsed.Hostname()
if h != "" && h != "localhost" && h != "127.0.0.1" {
callbackHost = h
bindAddr = "0.0.0.0"
}
}
// Start a local HTTP server on a random port to receive the callback.
listener, err := net.Listen("tcp", "127.0.0.1:0")
listener, err := net.Listen("tcp", bindAddr+":0")
if err != nil {
return fmt.Errorf("failed to start local server: %w", err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
callbackURL := fmt.Sprintf("http://localhost:%d/callback", port)
callbackURL := fmt.Sprintf("http://%s:%d/callback", callbackHost, port)
// Generate a random state parameter for CSRF protection.
stateBytes := make([]byte, 16)