security: close capability, CSP and auth-bridge holes
capabilities/default.json - Drop every `store:*` grant. `@tauri-apps/plugin-store` has no caller in `app/src`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData — `push` discards the base for an absolute path, so the grant was an arbitrary host read/write from the webview. - Replace `opener:default` with a scoped `opener:allow-open-url` (http/https only). That drops `reveal_item_in_dir`, which the plugin does not scope-check and nothing here calls, and the unused mailto:/tel: scope. - Record the unscopable `drag:allow-start-drag` residual risk in `description`. tauri.conf.json - Add `form-action 'none'`, `base-uri 'none'`, `object-src 'none'`. `form-action` has no `default-src` fallback, so an injected auto-submitting form was unblocked even though `script-src 'self'` blocks XSS. - Remove the dead `asset:` / `https://asset.localhost` img-src and `data:` font-src grants; `blob:` stays (the file viewer uses it). auth_bridge - The reserved-port set covered only this project's mappings and the two browser-view ranges. It now also covers the gateway, STT and web-terminal host ports (configured value and shipped default, read off the settings models) and every other project's published host port. A container binding container-loopback 4000 / 9876 / 7681 while those services were stopped had that port mirrored onto the host, unauthenticated, within one poll. - Gate the host listener on fetch metadata: refuse a request that is a cross-site sub-resource, allow navigations (the OAuth redirect) and anything without `Sec-Fetch-*`. Non-HTTP connections are classified from their first line and forwarded verbatim. Residual risk is spelled out in the module docs. - Bound the forwards: max concurrent connections per port, a first-byte deadline enforced before any `docker exec` is created, and an idle timeout. browser_view/mod.rs - `pick_viewer_port` reads procfs with `/usr/bin/cat`, not a bare `cat` the container can shim via its writable PATH entry. - Treat port choice as check-then-bind: walk to the next free candidate when the viewer does not come up, instead of failing the start. BrowserTab.tsx - Sandbox the viewer iframe. Container-controlled content could `top.location` the app's webview away. `allow-top-navigation*` and `allow-popups-to-escape-sandbox` are deliberately absent. HelpDialog.tsx - Escape the quote characters in the entity pass and escape captured attribute values. `href="$2"` with `$2` = `[^)]+` let remote GitHub markdown close the attribute and open another, in a document rendered with `dangerouslySetInnerHTML`. web_terminal/terminal.html - SRI hashes plus `crossorigin` on the three jsdelivr bundles and the stylesheet, and a CSP for the page — it is served 0.0.0.0 behind a permissive CORS layer and nothing else gives it one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -334,7 +334,10 @@ async fn poll_loop(
|
||||
Ok(text) => {
|
||||
exec_failures = 0;
|
||||
let discovered = proc_net::parse_loopback_listeners(&text);
|
||||
let skip = skipped_ports(&project);
|
||||
// Re-read every tick: a project can gain a port mapping and the
|
||||
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||
// bridge is running, and a stale reservation set is a hole.
|
||||
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||
emit_status(&app, &project_id, &state, true).await;
|
||||
}
|
||||
@@ -377,24 +380,101 @@ async fn poll_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Ports Docker already handles for this project. A container port that is
|
||||
/// explicitly published has a host-side path already, and the mapping's host
|
||||
/// port is a binding we must not fight over.
|
||||
/// Every port this project's bridge must not take.
|
||||
///
|
||||
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
||||
/// loopback listeners another feature owns and exposes on its own,
|
||||
/// authenticated terms.
|
||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
||||
/// The bridge's rule is "a container loopback listener on port N becomes an
|
||||
/// **unauthenticated** host listener on port N". That is only safe for ports
|
||||
/// nothing else on the host owns, so everything that *is* owned has to be
|
||||
/// enumerated here. Four sources:
|
||||
///
|
||||
/// 1. **This project's own published ports** — a container port that Docker
|
||||
/// already publishes has a host-side path, and the mapping's host port is a
|
||||
/// binding we must not fight over.
|
||||
/// 2. **Every other project's published host ports.** The container names the
|
||||
/// *host* port, so project A's container listening on 8080 would otherwise
|
||||
/// have the bridge bind host 8080 — the port project B publishes on. Only
|
||||
/// the host end of another project's mapping is reserved: its container end
|
||||
/// is a number inside a different network namespace and means nothing here.
|
||||
/// 3. **This app's own host services** — the LiteLLM gateway, the STT sidecar
|
||||
/// and the web terminal. All three are off by default and bind on demand, so
|
||||
/// first-come would win: a container that binds container-loopback 4000
|
||||
/// while the gateway is stopped gets host `127.0.0.1:4000` mirrored to it
|
||||
/// within one [`POLL_INTERVAL`], after which the gateway cannot start and
|
||||
/// anything on the host dialling 4000 — including *other project
|
||||
/// containers*, which reach the gateway by host address — is talking to the
|
||||
/// squatting container instead. The web terminal is the worst of the three,
|
||||
/// because its access token travels in the URL query. Both the *configured*
|
||||
/// port and the shipped default are reserved: the configured one is what the
|
||||
/// service will bind next, and the default is what it falls back to for a
|
||||
/// fresh profile or a settings file that failed to parse.
|
||||
/// 4. [`RESERVED_CONTAINER_PORTS`] and [`RESERVED_HOST_PORTS`] — the
|
||||
/// browser-view pane's two ends, which it exposes on its own authenticated
|
||||
/// terms.
|
||||
///
|
||||
/// Pure on purpose: everything it needs is passed in, so the whole reservation
|
||||
/// policy is unit-testable without a store, a container or an app handle.
|
||||
fn skipped_ports(
|
||||
project: &crate::models::Project,
|
||||
all_projects: &[crate::models::Project],
|
||||
settings: &crate::models::AppSettings,
|
||||
) -> HashSet<u16> {
|
||||
let mut skip: HashSet<u16> = project
|
||||
.port_mappings
|
||||
.iter()
|
||||
.flat_map(|m| [m.container_port, m.host_port])
|
||||
.collect();
|
||||
|
||||
// Other projects: host end only.
|
||||
skip.extend(
|
||||
all_projects
|
||||
.iter()
|
||||
.filter(|p| p.id != project.id)
|
||||
.flat_map(|p| p.port_mappings.iter().map(|m| m.host_port)),
|
||||
);
|
||||
|
||||
skip.extend(app_service_host_ports(settings));
|
||||
skip.extend(RESERVED_CONTAINER_PORTS.clone());
|
||||
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||
skip
|
||||
}
|
||||
|
||||
/// Current app settings, or defaults if the state is not reachable.
|
||||
///
|
||||
/// Falling back rather than unwrapping matters: the reservation set is a safety
|
||||
/// rail, and a rail that panics the poller when it cannot read its input is
|
||||
/// worse than one that falls back to the shipped port numbers — which are what
|
||||
/// the services use anyway until someone changes them.
|
||||
fn app_settings(app: &AppHandle) -> crate::models::AppSettings {
|
||||
use tauri::Manager;
|
||||
app.try_state::<crate::AppState>()
|
||||
.map(|state| state.settings_store.get())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Host ports this app's own sibling services bind, configured value and
|
||||
/// shipped default alike.
|
||||
///
|
||||
/// Read off the settings models rather than restated as literals here: a
|
||||
/// duplicated port number is exactly the kind of constant that drifts silently,
|
||||
/// and the failure mode of drift is a reservation that no longer covers the
|
||||
/// service it was written for.
|
||||
fn app_service_host_ports(settings: &crate::models::AppSettings) -> Vec<u16> {
|
||||
use crate::models::{SttSettings, WebTerminalSettings};
|
||||
|
||||
vec![
|
||||
// LiteLLM gateway (`docker/gateway.rs`).
|
||||
settings.gateway.port,
|
||||
crate::models::default_gateway_port(),
|
||||
// Speech-to-text sidecar (`docker/stt.rs`).
|
||||
settings.stt.port,
|
||||
SttSettings::default().port,
|
||||
// Remote web terminal (`web_terminal/server.rs`) — binds 0.0.0.0, and
|
||||
// its access token is in the URL query.
|
||||
settings.web_terminal.port,
|
||||
WebTerminalSettings::default().port,
|
||||
]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Reservations
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -591,7 +671,7 @@ async fn emit_status(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{PortMapping, Project, ProjectPath};
|
||||
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||
let mut p = Project::new(
|
||||
@@ -612,9 +692,14 @@ mod tests {
|
||||
p
|
||||
}
|
||||
|
||||
/// The common case: one project, no siblings, stock settings.
|
||||
fn skip_for(project: &Project) -> HashSet<u16> {
|
||||
skipped_ports(project, std::slice::from_ref(project), &AppSettings::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_already_published_by_docker_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
let skip = skip_for(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
|
||||
assert!(skip.contains(&3000));
|
||||
// Both ends of an asymmetric mapping are off limits: the container port
|
||||
// is already reachable, and the host port is Docker's binding.
|
||||
@@ -624,20 +709,96 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
||||
let settings = AppSettings::default();
|
||||
let project = project_with_mappings(vec![]);
|
||||
let skip = skip_for(&project);
|
||||
|
||||
let mut expected: HashSet<u16> = RESERVED_CONTAINER_PORTS.collect();
|
||||
expected.extend(RESERVED_HOST_PORTS);
|
||||
expected.extend(app_service_host_ports(&settings));
|
||||
assert_eq!(skip, expected);
|
||||
|
||||
// The ranges and the service ports are disjoint, so nothing above is
|
||||
// accidentally counting the same port twice.
|
||||
assert_eq!(
|
||||
skip.len(),
|
||||
RESERVED_CONTAINER_PORTS.clone().count() + RESERVED_HOST_PORTS.clone().count()
|
||||
RESERVED_CONTAINER_PORTS.clone().count()
|
||||
+ RESERVED_HOST_PORTS.clone().count()
|
||||
+ 3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_apps_own_host_services_are_never_taken() {
|
||||
// The bug this guards: the reserved set used to cover only the
|
||||
// browser-view ranges and this project's own mappings, so a container
|
||||
// binding container-loopback 4000 / 9876 / 7681 while the matching
|
||||
// service was stopped had that port mirrored, unauthenticated, onto the
|
||||
// host — taking the gateway's, the STT sidecar's or the web terminal's
|
||||
// door before they could bind it.
|
||||
let settings = AppSettings::default();
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
|
||||
assert!(skip.contains(&settings.gateway.port), "LiteLLM gateway port");
|
||||
assert!(skip.contains(&settings.stt.port), "STT sidecar port");
|
||||
assert!(skip.contains(&settings.web_terminal.port), "web terminal port");
|
||||
|
||||
// The shipped defaults, spelled out once so a change to any of them is
|
||||
// a change to this assertion and not a silent narrowing.
|
||||
assert!(skip.contains(&4000));
|
||||
assert!(skip.contains(&9876));
|
||||
assert!(skip.contains(&7681));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reconfigured_service_port_is_reserved_alongside_its_default() {
|
||||
let mut settings = AppSettings::default();
|
||||
settings.gateway.port = 4321;
|
||||
settings.stt.port = 9000;
|
||||
settings.web_terminal.port = 8443;
|
||||
let project = project_with_mappings(vec![]);
|
||||
let skip = skipped_ports(&project, std::slice::from_ref(&project), &settings);
|
||||
|
||||
for port in [4321, 9000, 8443] {
|
||||
assert!(skip.contains(&port), "configured port {} should be reserved", port);
|
||||
}
|
||||
// The default stays reserved too: it is what the service falls back to
|
||||
// for a fresh profile or an unparseable settings file, so leaving it
|
||||
// open is leaving the same squat available one restart later.
|
||||
for port in [4000, 9876, 7681] {
|
||||
assert!(skip.contains(&port), "default port {} should be reserved", port);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn another_projects_published_host_port_is_not_stolen() {
|
||||
// The container names the *host* port. Without this, project A's
|
||||
// container listening on 8080 takes the host 8080 that project B
|
||||
// publishes on — the bridge wins the race whenever B's container is not
|
||||
// running yet.
|
||||
let mine = project_with_mappings(vec![]);
|
||||
let mut theirs = project_with_mappings(vec![(8080, 3000)]);
|
||||
theirs.id = format!("{}-other", mine.id);
|
||||
|
||||
let skip = skipped_ports(
|
||||
&mine,
|
||||
&[mine.clone(), theirs.clone()],
|
||||
&AppSettings::default(),
|
||||
);
|
||||
assert!(skip.contains(&8080), "another project's host port");
|
||||
// …but not the other project's *container* port: that number lives in a
|
||||
// different network namespace and means nothing on this host, and
|
||||
// reserving it would refuse a legitimate login callback for no reason.
|
||||
assert!(!skip.contains(&3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_views_host_ports_are_never_taken() {
|
||||
// The bridge binds *host* ports chosen by the container, so without
|
||||
// this it can take the port the browser-view proxy will want later —
|
||||
// that pane binds on demand, so first-come would win.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_HOST_PORTS {
|
||||
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
||||
}
|
||||
@@ -693,14 +854,14 @@ mod tests {
|
||||
// Mirroring these would publish an ungated second door to the
|
||||
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||
// token-checking listener.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
||||
let skip = skip_for(&project_with_mappings(vec![]));
|
||||
for port in RESERVED_CONTAINER_PORTS {
|
||||
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||
}
|
||||
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||
|
||||
// Reservations coexist with Docker's own published ports.
|
||||
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000)]));
|
||||
let skip = skip_for(&project_with_mappings(vec![(3000, 3000)]));
|
||||
assert!(skip.contains(RESERVED_CONTAINER_PORTS.start()));
|
||||
assert!(skip.contains(&3000));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user