From a6ad80ac4dbaf5bf9909410876b91cbd3c815659 Mon Sep 17 00:00:00 2001 From: RealZST Date: Tue, 30 Jun 2026 22:17:38 +0300 Subject: [PATCH] feat(serve): require an auth token by default, persisted at ~/.harnesskit/web-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-generate and enforce a web access token even for 127.0.0.1 binds. On a shared host (e.g. an HPC login node) loopback is not isolated per user, so binding 127.0.0.1 alone does not keep other local users out — only the token does. Previously a token was generated only for non-localhost binds, leaving the common default wide open on shared login nodes. - Persist the token at ~/.harnesskit/web-token (mode 0600) and reuse it across restarts; a file with looser permissions is treated as compromised and regenerated. - Add --no-token to opt out on a trusted single-user machine. - Embed the token in the 127.0.0.1 startup URL, and store it in localStorage so one ?token= visit logs in and survives new tabs/reloads. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + README.md | 10 +- README.zh-CN.md | 10 +- crates/hk-cli/Cargo.toml | 3 + crates/hk-cli/src/main.rs | 171 ++++++++++++++++++++++++++-- crates/hk-web/src/lib.rs | 8 +- src/lib/__tests__/transport.test.ts | 8 +- src/lib/transport.ts | 6 +- 8 files changed, 186 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 992d9cd5..22901a0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1935,6 +1935,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", + "tempfile", "tokio", ] diff --git a/README.md b/README.md index c9e2ab07..4ddc0660 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ The same full-featured UI that runs in the desktop app is also available as a ** ```shell $ hk serve -HarnessKit Web UI running at http://127.0.0.1:7070 +HarnessKit Web UI [my-host] running at http://127.0.0.1:7070/?token=a1b2c3… ``` This makes HarnessKit usable on **Linux servers**, **HPC clusters**, or any **headless machine** where a desktop app isn't an option. Web mode has **full feature parity** with the desktop app — the only difference is that file-system operations (like "Open in Finder") are desktop-only. See [Getting Started](#getting-started) for setup instructions. @@ -255,7 +255,7 @@ Already installed? Open **Settings → Check for Updates** to upgrade in-app. hk serve ``` - Then open `http://localhost:7070` in your browser. + Then open the `http://localhost:7070/?token=…` URL that `hk serve` prints. Auth is on by default; the token is saved, so next time `http://localhost:7070` just works. On a trusted single-user machine, `hk serve --no-token` skips the token entirely. #### Remote server @@ -282,7 +282,7 @@ Already installed? Open **Settings → Check for Updates** to upgrade in-app. hk serve ``` - Then open `http://localhost:7070` in your local browser. Keep the SSH session running while you use HarnessKit. + Then open the `http://localhost:7070/?token=…` URL that `hk serve` prints, in your local browser. Auth is on by default; the token is saved, so next time `http://localhost:7070` just works. Keep the SSH session running while you use HarnessKit. > **Tip:** Managing several remote nodes? Start each with `hk serve --name @@ -324,7 +324,7 @@ Download the binary for your platform from the [latest release](https://github.c hk serve ``` - Then open `http://localhost:7070` in your browser. + Then open the `http://localhost:7070/?token=…` URL that `hk serve` prints. Auth is on by default; the token is saved, so next time `http://localhost:7070` just works. On a trusted single-user machine, `hk serve --no-token` skips the token entirely. **Remote server:** @@ -346,7 +346,7 @@ Download the binary for your platform from the [latest release](https://github.c hk serve ``` - Then open `http://localhost:7070` in your local browser. Keep the SSH session running while you use HarnessKit. + Then open the `http://localhost:7070/?token=…` URL that `hk serve` prints, in your local browser. Auth is on by default; the token is saved, so next time `http://localhost:7070` just works. Keep the SSH session running while you use HarnessKit. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9a1f48f9..a6308020 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -191,7 +191,7 @@ $ hk disable --pack owner/repo # 按来源批量禁用 ```shell $ hk serve -HarnessKit Web UI running at http://127.0.0.1:7070 +HarnessKit Web UI [my-host] running at http://127.0.0.1:7070/?token=a1b2c3… ``` 这意味着 **Linux 服务器**、**HPC 集群** 或任何 **无图形界面的机器** 上也能运行 HarnessKit —— 这些都是桌面应用难以触达的场景。Web 模式的功能与桌面版完全一致,仅少数与系统文件管理器相关的操作(如「在访达中打开」)需要桌面版。安装步骤见 [快速开始](#快速开始)。 @@ -255,7 +255,7 @@ HarnessKit Web UI running at http://127.0.0.1:7070 hk serve ``` - 然后在浏览器打开 `http://localhost:7070`。 + 然后打开 `hk serve` 打印出的 `http://localhost:7070/?token=…` 链接。鉴权默认开启;token 会被保存,下次直接打开 `http://localhost:7070` 即可。可信的单用户机器上,用 `hk serve --no-token` 可完全跳过 token。 #### 远程服务器 @@ -282,7 +282,7 @@ HarnessKit Web UI running at http://127.0.0.1:7070 hk serve ``` - 然后在本地浏览器打开 `http://localhost:7070`。使用 HarnessKit 期间请保持该 SSH 会话开启。 + 然后在本地浏览器打开 `hk serve` 打印出的 `http://localhost:7070/?token=…` 链接。鉴权默认开启;token 会被保存,下次直接打开 `http://localhost:7070` 即可。使用 HarnessKit 期间请保持该 SSH 会话开启。 > **提示:** 管理多个远程节点时,用 `hk serve --name <标签>` 启动(如 `--name my-macbook`)。标签会显示在侧边栏和浏览器标签页标题里,多个 tab 一眼就能区分。默认取机器主机名。 @@ -324,7 +324,7 @@ HarnessKit Web UI running at http://127.0.0.1:7070 hk serve ``` - 然后在浏览器打开 `http://localhost:7070`。 + 然后打开 `hk serve` 打印出的 `http://localhost:7070/?token=…` 链接。鉴权默认开启;token 会被保存,下次直接打开 `http://localhost:7070` 即可。可信的单用户机器上,用 `hk serve --no-token` 可完全跳过 token。 **远程服务器:** @@ -346,7 +346,7 @@ HarnessKit Web UI running at http://127.0.0.1:7070 hk serve ``` - 然后在本地浏览器打开 `http://localhost:7070`。使用 HarnessKit 期间请保持该 SSH 会话开启。 + 然后在本地浏览器打开 `hk serve` 打印出的 `http://localhost:7070/?token=…` 链接。鉴权默认开启;token 会被保存,下次直接打开 `http://localhost:7070` 即可。使用 HarnessKit 期间请保持该 SSH 会话开启。 diff --git a/crates/hk-cli/Cargo.toml b/crates/hk-cli/Cargo.toml index e7299014..b413f316 100644 --- a/crates/hk-cli/Cargo.toml +++ b/crates/hk-cli/Cargo.toml @@ -19,3 +19,6 @@ dirs = "6" hk-web = { path = "../hk-web" } tokio = { version = "1", features = ["full"] } rand = "0.9" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/hk-cli/src/main.rs b/crates/hk-cli/src/main.rs index 41276919..181db729 100644 --- a/crates/hk-cli/src/main.rs +++ b/crates/hk-cli/src/main.rs @@ -86,10 +86,19 @@ enum Commands { #[arg(long, default_value = "127.0.0.1")] host: String, - /// Access token (auto-generated for non-localhost binds if omitted) + /// Access token. If omitted, a persistent token is auto-generated and + /// stored at ~/.harnesskit/web-token (mode 0600). Prefer this over + /// passing --token on shared hosts: command-line args are visible to + /// other users via `ps`/`/proc//cmdline`. #[arg(long)] token: Option, + /// Disable authentication entirely (no token). Only safe on a trusted + /// single-user machine — on a shared host (e.g. an HPC login node) + /// loopback is not isolated per-user, so any local user could connect. + #[arg(long)] + no_token: bool, + /// Node label shown in the web UI (defaults to the machine hostname). /// Useful when opening multiple tabs against different remote nodes. #[arg(long)] @@ -100,16 +109,8 @@ enum Commands { fn main() -> Result<()> { let cli = Cli::parse(); - if let Commands::Serve { port, host, token, name } = cli.command { - let effective_token = if host != "127.0.0.1" { - Some(token.unwrap_or_else(|| { - use rand::Rng; - let token_value: u128 = rand::rng().random(); - format!("{token_value:032x}") - })) - } else { - token - }; + if let Commands::Serve { port, host, token, no_token, name } = cli.command { + let effective_token = resolve_serve_token(token, no_token); let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(hk_web::serve(hk_web::ServeOptions { @@ -182,6 +183,99 @@ fn hk_data_dir() -> PathBuf { dirs::home_dir().unwrap_or_default().join(".harnesskit") } +/// Resolve the web access token for `hk serve`. +/// +/// Secure-by-default: with neither `--token` nor `--no-token`, a persistent +/// token is loaded (or generated) from `~/.harnesskit/web-token` and enforced +/// even for `127.0.0.1` binds. On a shared host (HPC login node) the loopback +/// interface is not isolated per-user, so binding `127.0.0.1` alone does not +/// keep other local users out — only the token does. +fn resolve_serve_token(explicit: Option, no_token: bool) -> Option { + if no_token { + return None; + } + if let Some(token) = explicit { + return Some(token); + } + Some(load_or_create_token()) +} + +/// Generate a 128-bit random token rendered as 32 hex chars. +fn generate_token() -> String { + use rand::Rng; + let token_value: u128 = rand::rng().random(); + format!("{token_value:032x}") +} + +/// Load the persisted token from `~/.harnesskit/web-token`, or create one. +/// +/// The file is created/rewritten with mode `0600` (owner-only). An existing +/// file is reused only if it is non-empty and owner-only — if its permissions +/// are looser than `0600` (e.g. left world-readable by an older version or a +/// hostile pre-creation), the token is treated as compromised and regenerated. +/// Falls back to an in-memory token if the file cannot be persisted. +fn load_or_create_token() -> String { + let path = hk_data_dir().join("web-token"); + + if let Some(token) = read_token_if_secure(&path) { + return token; + } + + let token = generate_token(); + if let Err(err) = write_token_0600(&path, &token) { + eprintln!( + "warning: could not persist web token to {}: {err}. Using a one-time token for this run.", + path.display() + ); + } + token +} + +/// Return the stored token only if the file exists, is non-empty, and (on Unix) +/// is owner-only (`0600`). Returns `None` otherwise so the caller regenerates. +fn read_token_if_secure(path: &std::path::Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Reject anything readable/writable by group or others. + let metadata = std::fs::metadata(path).ok()?; + if metadata.permissions().mode() & 0o077 != 0 { + return None; + } + } + let token = std::fs::read_to_string(path).ok()?; + let token = token.trim().to_string(); + if token.is_empty() { None } else { Some(token) } +} + +/// Write the token with owner-only permissions, creating the data dir first. +fn write_token_0600(path: &std::path::Path, token: &str) -> std::io::Result<()> { + use std::io::Write; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + + // `OpenOptions::mode` is ignored when the file already exists, so set the + // permissions explicitly to harden a pre-existing, looser-permissioned file. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + + file.write_all(token.as_bytes())?; + Ok(()) +} + /// Build a grouping key matching the desktop's `extensionGroupKey`: /// `kind \0 name \0 origin \0 developer` /// For hooks, strip event/matcher prefix and keep only the command part. @@ -508,3 +602,58 @@ fn format_score(score: u8) -> String { TrustTier::NeedsReview => format!("{score}").truecolor(255, 165, 0).to_string(), } } + +#[cfg(test)] +mod token_persistence_tests { + use super::{read_token_if_secure, write_token_0600}; + + /// Write persists the token and read reads it back; on Unix the file is + /// owner-only (0600). + #[test] + fn write_persists_token_owner_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("web-token"); + + write_token_0600(&path, "deadbeef").unwrap(); + + assert_eq!(read_token_if_secure(&path).as_deref(), Some("deadbeef")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "token file must be owner-only"); + } + } + + /// A token file readable by group or others is refused, so the caller + /// regenerates rather than trusting a token other users could have read. + #[cfg(unix)] + #[test] + fn read_rejects_group_or_world_readable_token() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("web-token"); + std::fs::write(&path, "deadbeef").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + assert_eq!(read_token_if_secure(&path), None); + } + + /// Overwriting an existing token file resets its permissions to owner-only, + /// even if it was previously group/world-readable. + #[cfg(unix)] + #[test] + fn write_rehardens_preexisting_loose_file() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("web-token"); + std::fs::write(&path, "old").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + write_token_0600(&path, "new").unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + assert_eq!(read_token_if_secure(&path).as_deref(), Some("new")); + } +} diff --git a/crates/hk-web/src/lib.rs b/crates/hk-web/src/lib.rs index 5ee11a8f..e9ef307e 100644 --- a/crates/hk-web/src/lib.rs +++ b/crates/hk-web/src/lib.rs @@ -66,9 +66,9 @@ pub async fn serve(options: ServeOptions) -> anyhow::Result<()> { let app = router::build_router(state); let addr: SocketAddr = format!("{}:{}", options.host, options.port).parse()?; - // When auth is enabled (non-localhost binds), embed the token in the URL so - // the user can paste a single link and be logged in — the frontend reads it - // and strips it from the address bar. Mirrors Jupyter's `?token=` flow. + // When auth is enabled, embed the token in the URL so the user can paste a + // single link and be logged in — the frontend reads it and strips it from + // the address bar. Mirrors Jupyter's `?token=` flow. let token_query = options .token .as_deref() @@ -77,7 +77,7 @@ pub async fn serve(options: ServeOptions) -> anyhow::Result<()> { match options.host.as_str() { "127.0.0.1" => { - eprintln!("HarnessKit Web UI [{node_name}] running at http://{addr}"); + eprintln!("HarnessKit Web UI [{node_name}] running at http://{addr}{token_query}"); eprintln!("Access via SSH tunnel: ssh -L {p}:localhost:{p} your-server", p = options.port); } // 0.0.0.0 binds every interface but is not itself a reachable address, diff --git a/src/lib/__tests__/transport.test.ts b/src/lib/__tests__/transport.test.ts index 089a30db..86584fd7 100644 --- a/src/lib/__tests__/transport.test.ts +++ b/src/lib/__tests__/transport.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { consumeUrlToken, getAuthToken } from "@/lib/transport"; beforeEach(() => { - sessionStorage.clear(); + localStorage.clear(); // Reset the URL between tests (jsdom keeps the last replaceState value). window.history.replaceState({}, "", "/"); }); @@ -14,7 +14,7 @@ describe("consumeUrlToken", () => { consumeUrlToken(); expect(getAuthToken()).toBe("abc123"); - expect(sessionStorage.getItem("hk_token")).toBe("abc123"); + expect(localStorage.getItem("hk_token")).toBe("abc123"); expect(window.location.search).toBe(""); }); @@ -23,7 +23,7 @@ describe("consumeUrlToken", () => { consumeUrlToken(); - expect(sessionStorage.getItem("hk_token")).toBe("abc123"); + expect(localStorage.getItem("hk_token")).toBe("abc123"); expect(window.location.search).toBe("?scope=all"); }); @@ -32,7 +32,7 @@ describe("consumeUrlToken", () => { consumeUrlToken(); - expect(sessionStorage.getItem("hk_token")).toBeNull(); + expect(localStorage.getItem("hk_token")).toBeNull(); expect(window.location.search).toBe("?scope=all"); }); }); diff --git a/src/lib/transport.ts b/src/lib/transport.ts index a205c128..c0ba68f4 100644 --- a/src/lib/transport.ts +++ b/src/lib/transport.ts @@ -37,12 +37,14 @@ let authToken: string | null = null; export function setAuthToken(token: string): void { authToken = token; - sessionStorage.setItem("hk_token", token); + // localStorage (not sessionStorage) so the token survives across new tabs + // and reloads — the user only has to open the `?token=` URL once. + localStorage.setItem("hk_token", token); } export function getAuthToken(): string | null { if (authToken) return authToken; - authToken = sessionStorage.getItem("hk_token"); + authToken = localStorage.getItem("hk_token"); return authToken; }