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:
@@ -304,21 +304,7 @@ impl BrowserViewManager {
|
||||
// a container with no dashboard makes this a no-op.
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
|
||||
let container_port = pick_viewer_port(&container_id).await?;
|
||||
launch_viewer(&container_id, &cli_entry, container_port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
let entry_path = match wait_until_ready(&container_id, container_port).await {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(&container_id).await;
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
return Err(explain_start_failure(&e, &log));
|
||||
}
|
||||
};
|
||||
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
|
||||
|
||||
let token = generate_token();
|
||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||
@@ -601,12 +587,91 @@ async fn read_viewer_log(container_id: &str) -> String {
|
||||
// Readiness, ports, URLs
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
|
||||
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
||||
/// How many free ports a start will try before giving up.
|
||||
///
|
||||
/// More than one because port choice is a check-then-bind: the free list comes
|
||||
/// from a snapshot of the container's `/proc/net/tcp`, and anything in the
|
||||
/// container may bind the port we picked before the dashboard gets to it. One
|
||||
/// retry per lost race is the recovery; the cap is what stops a container that
|
||||
/// binds every candidate from holding a start open for
|
||||
/// `MAX_PORT_ATTEMPTS × READY_TIMEOUT`.
|
||||
const MAX_PORT_ATTEMPTS: usize = 3;
|
||||
|
||||
/// Get a viewer listening inside the container and return the port it is on
|
||||
/// plus the path the pane should load.
|
||||
///
|
||||
/// ## The check/bind race
|
||||
///
|
||||
/// [`pick_viewer_port`] reads a *snapshot* of container listeners; the dashboard
|
||||
/// binds some milliseconds later. Nothing here can make that atomic — the bind
|
||||
/// happens in another process, in another namespace, and `playwright-cli show`
|
||||
/// reports the port it actually took only on a first-ever start (see
|
||||
/// [`wait_until_ready`]). What is possible is to stop treating the first
|
||||
/// candidate as the only one: if the port we picked does not come up, walk to
|
||||
/// the next free candidate rather than failing the whole start.
|
||||
///
|
||||
/// Residual, stated rather than glossed: a container-side process that binds the
|
||||
/// candidate port *and answers HTTP* is indistinguishable from the dashboard at
|
||||
/// this layer, and the pane would then front it. What contains that is
|
||||
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
|
||||
/// iframe is sandboxed — not this function.
|
||||
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
|
||||
let mut tried: Vec<u16> = Vec::new();
|
||||
let mut last: Option<String> = None;
|
||||
|
||||
for _ in 0..MAX_PORT_ATTEMPTS {
|
||||
// Re-read the listener snapshot each attempt: the port that was free a
|
||||
// moment ago is exactly the one we may have just lost.
|
||||
let port = match pick_viewer_port(container_id, &tried).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Report why the *attempts* failed, not just "nothing free":
|
||||
// the exhausted range is the symptom, the last start failure is
|
||||
// the thing the user can act on.
|
||||
return Err(match last {
|
||||
Some(prev) => format!("{} ({})", e, prev),
|
||||
None => e,
|
||||
});
|
||||
}
|
||||
};
|
||||
tried.push(port);
|
||||
|
||||
launch_viewer(container_id, cli_entry, port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
match wait_until_ready(container_id, port).await {
|
||||
Ok(path) => return Ok((port, path)),
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(container_id).await;
|
||||
// Always kill before retrying: the dashboard is a singleton, so
|
||||
// a launcher that came up on some *other* port would otherwise
|
||||
// make every further attempt a no-op that silently ignores the
|
||||
// port we asked for.
|
||||
let _ = kill_dashboard(container_id, cli_entry).await;
|
||||
last = Some(explain_start_failure(&e, &log));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last.unwrap_or_else(|| "The Playwright viewer did not start.".to_string()))
|
||||
}
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
|
||||
/// and that this start has not already tried.
|
||||
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = exec_oneshot(
|
||||
container_id,
|
||||
vec![
|
||||
"cat".to_string(),
|
||||
// Absolute path, deliberately, for the same reason the auth bridge
|
||||
// uses one: `container/Dockerfile` puts a container-writable
|
||||
// directory first on `PATH`, so a bare `cat` is a name the container
|
||||
// can rebind to a shim. A shimmed listener list is a shimmed answer
|
||||
// to "which port is free" — i.e. the container choosing which port
|
||||
// the viewer, and therefore the host-side proxy, ends up on.
|
||||
"/usr/bin/cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
],
|
||||
@@ -616,7 +681,7 @@ async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
||||
let taken = proc_net::parse_loopback_listeners(&text);
|
||||
VIEWER_PORTS
|
||||
.clone()
|
||||
.find(|p| !taken.contains_key(p))
|
||||
.find(|p| !taken.contains_key(p) && !tried.contains(p))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No free port in {}–{} inside the container for the Playwright viewer.",
|
||||
|
||||
Reference in New Issue
Block a user