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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capabilities for Triple-C",
|
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is kept to what the frontend actually uses. Two notes on what is deliberately absent and what is deliberately accepted: (1) the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). (2) `drag:allow-start-drag` stays, and cannot be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read. It is not a silent exfiltration primitive: the drag only delivers anything if the user completes a real drop onto a real target, and the OS shows the drag under the cursor while it is in flight. Removing it would remove drag-out from the Files pane (`stage_container_file_for_drag`), whose fallback is the explicit \"Save to host…\" action. Accepted residual risk, recorded here rather than fixed.",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
@@ -15,20 +15,10 @@
|
|||||||
"dialog:allow-message",
|
"dialog:allow-message",
|
||||||
"dialog:allow-ask",
|
"dialog:allow-ask",
|
||||||
"dialog:allow-confirm",
|
"dialog:allow-confirm",
|
||||||
"store:default",
|
{
|
||||||
"store:allow-get",
|
"identifier": "opener:allow-open-url",
|
||||||
"store:allow-set",
|
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
|
||||||
"store:allow-delete",
|
},
|
||||||
"store:allow-keys",
|
|
||||||
"store:allow-values",
|
|
||||||
"store:allow-entries",
|
|
||||||
"store:allow-length",
|
|
||||||
"store:allow-load",
|
|
||||||
"store:allow-reset",
|
|
||||||
"store:allow-save",
|
|
||||||
"store:allow-clear",
|
|
||||||
"opener:default",
|
|
||||||
"opener:allow-open-url",
|
|
||||||
"drag:default",
|
"drag:default",
|
||||||
"drag:allow-start-drag"
|
"drag:allow-start-drag"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm","store:default","store:allow-get","store:allow-set","store:allow-delete","store:allow-keys","store:allow-values","store:allow-entries","store:allow-length","store:allow-load","store:allow-reset","store:allow-save","store:allow-clear","opener:default","opener:allow-open-url","drag:default","drag:allow-start-drag"]}}
|
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is kept to what the frontend actually uses. Two notes on what is deliberately absent and what is deliberately accepted: (1) the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). (2) `drag:allow-start-drag` stays, and cannot be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read. It is not a silent exfiltration primitive: the drag only delivers anything if the user completes a real drop onto a real target, and the OS shows the drag under the cursor while it is in flight. Removing it would remove drag-out from the Files pane (`stage_container_file_for_drag`), whose fallback is the explicit \"Save to host…\" action. Accepted residual risk, recorded here rather than fixed.","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-emit","core:event:allow-listen","core:event:allow-unlisten","core:event:allow-emit-to","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","dialog:allow-confirm",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]},"drag:default","drag:allow-start-drag"]}}
|
||||||
@@ -334,7 +334,10 @@ async fn poll_loop(
|
|||||||
Ok(text) => {
|
Ok(text) => {
|
||||||
exec_failures = 0;
|
exec_failures = 0;
|
||||||
let discovered = proc_net::parse_loopback_listeners(&text);
|
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 {
|
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||||
emit_status(&app, &project_id, &state, true).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
|
/// Every port this project's bridge must not take.
|
||||||
/// explicitly published has a host-side path already, and the mapping's host
|
|
||||||
/// port is a binding we must not fight over.
|
|
||||||
///
|
///
|
||||||
/// [`RESERVED_CONTAINER_PORTS`] is folded in as well: those are container
|
/// The bridge's rule is "a container loopback listener on port N becomes an
|
||||||
/// loopback listeners another feature owns and exposes on its own,
|
/// **unauthenticated** host listener on port N". That is only safe for ports
|
||||||
/// authenticated terms.
|
/// nothing else on the host owns, so everything that *is* owned has to be
|
||||||
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
|
/// 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
|
let mut skip: HashSet<u16> = project
|
||||||
.port_mappings
|
.port_mappings
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|m| [m.container_port, m.host_port])
|
.flat_map(|m| [m.container_port, m.host_port])
|
||||||
.collect();
|
.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_CONTAINER_PORTS.clone());
|
||||||
skip.extend(RESERVED_HOST_PORTS.clone());
|
skip.extend(RESERVED_HOST_PORTS.clone());
|
||||||
skip
|
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
|
// Reservations
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -591,7 +671,7 @@ async fn emit_status(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::models::{PortMapping, Project, ProjectPath};
|
use crate::models::{AppSettings, PortMapping, Project, ProjectPath};
|
||||||
|
|
||||||
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
|
||||||
let mut p = Project::new(
|
let mut p = Project::new(
|
||||||
@@ -612,9 +692,14 @@ mod tests {
|
|||||||
p
|
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]
|
#[test]
|
||||||
fn ports_already_published_by_docker_are_skipped() {
|
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));
|
assert!(skip.contains(&3000));
|
||||||
// Both ends of an asymmetric mapping are off limits: the container port
|
// Both ends of an asymmetric mapping are off limits: the container port
|
||||||
// is already reachable, and the host port is Docker's binding.
|
// is already reachable, and the host port is Docker's binding.
|
||||||
@@ -624,20 +709,96 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_mappings_means_nothing_but_the_reserved_ranges_are_skipped() {
|
fn no_mappings_means_nothing_but_the_reservations_are_skipped() {
|
||||||
let skip = skipped_ports(&project_with_mappings(vec![]));
|
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!(
|
assert_eq!(
|
||||||
skip.len(),
|
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]
|
#[test]
|
||||||
fn the_browser_views_host_ports_are_never_taken() {
|
fn the_browser_views_host_ports_are_never_taken() {
|
||||||
// The bridge binds *host* ports chosen by the container, so without
|
// The bridge binds *host* ports chosen by the container, so without
|
||||||
// this it can take the port the browser-view proxy will want later —
|
// this it can take the port the browser-view proxy will want later —
|
||||||
// that pane binds on demand, so first-come would win.
|
// 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 {
|
for port in RESERVED_HOST_PORTS {
|
||||||
assert!(skip.contains(&port), "host port {} should be reserved", port);
|
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
|
// Mirroring these would publish an ungated second door to the
|
||||||
// Playwright dashboard, which the pane deliberately keeps behind a
|
// Playwright dashboard, which the pane deliberately keeps behind a
|
||||||
// token-checking listener.
|
// 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 {
|
for port in RESERVED_CONTAINER_PORTS {
|
||||||
assert!(skip.contains(&port), "port {} should be reserved", port);
|
assert!(skip.contains(&port), "port {} should be reserved", port);
|
||||||
}
|
}
|
||||||
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
assert!(!skip.contains(&(RESERVED_CONTAINER_PORTS.end() + 1)));
|
||||||
|
|
||||||
// Reservations coexist with Docker's own published ports.
|
// 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(RESERVED_CONTAINER_PORTS.start()));
|
||||||
assert!(skip.contains(&3000));
|
assert!(skip.contains(&3000));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,48 @@
|
|||||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||||
//! interactive terminal sessions are built on.
|
//! interactive terminal sessions are built on.
|
||||||
|
//!
|
||||||
|
//! ## What the host listener is, and is not
|
||||||
|
//!
|
||||||
|
//! The listener is **not authenticated**, and cannot be. The port number is
|
||||||
|
//! chosen by whatever CLI is logging in, the redirect URL is the provider's, and
|
||||||
|
//! nothing in that chain can be taught to present a token — so there is no path
|
||||||
|
//! token to add. Anything that can reach `127.0.0.1:<port>` on this host reaches
|
||||||
|
//! the container-side listener. That includes **any web page the user has open**,
|
||||||
|
//! which can port-scan loopback from script.
|
||||||
|
//!
|
||||||
|
//! Two things narrow that, and neither is a substitute for the other:
|
||||||
|
//!
|
||||||
|
//! * The whole feature is opt-in per project, off by default, and only mirrors
|
||||||
|
//! ports while its container is running.
|
||||||
|
//! * [`web_request_verdict`] refuses the one case that is unambiguously a web
|
||||||
|
//! page reaching in: a request whose fetch metadata says it is a cross-site
|
||||||
|
//! **sub-resource** (`fetch`, `XMLHttpRequest`, `<img>`, `<script src>`,
|
||||||
|
//! `<iframe>`). Cross-site *navigations* are allowed, because that is exactly
|
||||||
|
//! what an OAuth redirect is.
|
||||||
|
//!
|
||||||
|
//! The residual risk, stated plainly rather than papered over: a client that
|
||||||
|
//! sends no `Sec-Fetch-Site` header at all is not filtered — that is every
|
||||||
|
//! non-browser client (which is the point; `curl`, a CLI, the container's own
|
||||||
|
//! probe must all still work) but also any browser predating fetch metadata
|
||||||
|
//! (Chrome < 76, Firefox < 90, Safari < 16.4). A page can also still reach the
|
||||||
|
//! port with a top-level navigation it opens itself (`window.open`), which
|
||||||
|
//! carries `Sec-Fetch-Mode: navigate` and is indistinguishable from the redirect
|
||||||
|
//! the bridge exists to deliver. And nothing here inspects *what* is behind the
|
||||||
|
//! port: if the container has something more interesting than a throwaway OAuth
|
||||||
|
//! listener on loopback, a same-machine caller reaches it.
|
||||||
|
//!
|
||||||
|
//! ## Bounds
|
||||||
|
//!
|
||||||
|
//! Every accepted connection costs a `docker exec`, and the number of
|
||||||
|
//! connections is decided by whoever can reach the port. So each forward caps
|
||||||
|
//! concurrent connections ([`MAX_CONNECTIONS`]), refuses a client that opens a
|
||||||
|
//! socket and then says nothing ([`FIRST_BYTE_TIMEOUT`], enforced *before* the
|
||||||
|
//! exec is created), and drops a connection the container has gone quiet on
|
||||||
|
//! ([`IDLE_TIMEOUT`]).
|
||||||
|
|
||||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use bollard::container::LogOutput;
|
use bollard::container::LogOutput;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
@@ -30,6 +70,38 @@ use super::proc_net::PortFamily;
|
|||||||
/// only needs to not be pathological.
|
/// only needs to not be pathological.
|
||||||
const PUMP_BUF: usize = 16 * 1024;
|
const PUMP_BUF: usize = 16 * 1024;
|
||||||
|
|
||||||
|
/// Concurrent connections one forwarded port will carry.
|
||||||
|
///
|
||||||
|
/// Each one is a `docker exec`, and the client side is anything on the host that
|
||||||
|
/// can dial loopback — including a web page in a loop. A login callback is one
|
||||||
|
/// connection, occasionally a handful; this is generous for that and still a
|
||||||
|
/// bound the engine will not notice.
|
||||||
|
const MAX_CONNECTIONS: usize = 16;
|
||||||
|
|
||||||
|
/// How long an accepted connection has to send its first byte before it is
|
||||||
|
/// dropped, *without* a `docker exec` ever being created for it.
|
||||||
|
///
|
||||||
|
/// This is a deliberate narrowing of what the bridge carries: a client that
|
||||||
|
/// connects and says nothing is not the HTTP OAuth callback this exists for, and
|
||||||
|
/// forwarding it costs a container exec for a socket that may never speak. A
|
||||||
|
/// server-speaks-first protocol behind a bridged port would be refused by this;
|
||||||
|
/// that is the trade, and it is the only protocol shape affected.
|
||||||
|
const FIRST_BYTE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// How long a live connection may go with nothing coming back from the container
|
||||||
|
/// before it is torn down. Generous, because a bridged port is not always a
|
||||||
|
/// short OAuth callback — but finite, so an abandoned connection cannot pin an
|
||||||
|
/// exec forever.
|
||||||
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
|
/// Ceiling on the request head buffered for [`web_request_verdict`]. Real heads
|
||||||
|
/// are well under 8 KiB; past this we stop looking and forward what we have.
|
||||||
|
const MAX_HEAD: usize = 32 * 1024;
|
||||||
|
|
||||||
|
/// How long the rest of a request head has, once the first line has identified
|
||||||
|
/// the connection as HTTP. Only a stalled or hostile client reaches it.
|
||||||
|
const HEAD_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
|
||||||
/// child running.
|
/// child running.
|
||||||
struct AbortOnDrop(JoinHandle<()>);
|
struct AbortOnDrop(JoinHandle<()>);
|
||||||
@@ -167,6 +239,22 @@ async fn accept_loop(
|
|||||||
|
|
||||||
match accepted {
|
match accepted {
|
||||||
Ok((stream, peer)) => {
|
Ok((stream, peer)) => {
|
||||||
|
// Reap first, so the cap counts *live* connections rather than
|
||||||
|
// every one this listener has ever accepted.
|
||||||
|
while conns.try_join_next().is_some() {}
|
||||||
|
if conns.len() >= MAX_CONNECTIONS {
|
||||||
|
// Dropping the stream closes it. Better than queueing: the
|
||||||
|
// client side is whatever can dial loopback, so a queue is
|
||||||
|
// just a slower way to run out of execs.
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: refusing connection from {} to bridged port {} — \
|
||||||
|
{} concurrent connections already open on it",
|
||||||
|
peer,
|
||||||
|
port,
|
||||||
|
MAX_CONNECTIONS
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||||
let _ = stream.set_nodelay(true);
|
let _ = stream.set_nodelay(true);
|
||||||
conns.spawn(tunnel_connection(
|
conns.spawn(tunnel_connection(
|
||||||
@@ -195,9 +283,224 @@ async fn accept_optional(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Carry one accepted host connection into the container over `socat`.
|
/// Carry one accepted host connection into the container over `socat`, after
|
||||||
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
/// deciding it is not a web page reaching into loopback.
|
||||||
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
///
|
||||||
|
/// Nothing is forwarded until that decision is made, so a refused request never
|
||||||
|
/// reaches the container at all — not even a `docker exec`.
|
||||||
|
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
|
||||||
|
let head = match read_leading_bytes(&mut stream).await {
|
||||||
|
Ok(head) => head,
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: dropping connection to bridged port {} before forwarding: {}",
|
||||||
|
port,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let LeadingBytes::HttpRequest { buffer, head_len } = &head {
|
||||||
|
// Authorize against the head slice only. Parsing past the blank line is
|
||||||
|
// how a request *body* gets read as headers — a cross-site `fetch` with
|
||||||
|
// a `text/plain` body is not preflighted, so it can put any line it
|
||||||
|
// likes in there.
|
||||||
|
let head_text = String::from_utf8_lossy(&buffer[..*head_len]);
|
||||||
|
if web_request_verdict(&head_text) == Verdict::RefuseCrossSite {
|
||||||
|
log::warn!(
|
||||||
|
"Auth bridge: refused a cross-site sub-resource request to bridged port {} — \
|
||||||
|
a web page, not a login redirect",
|
||||||
|
port
|
||||||
|
);
|
||||||
|
let _ = refuse(&mut stream).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||||
|
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the first bytes of an accepted connection turned out to be.
|
||||||
|
enum LeadingBytes {
|
||||||
|
/// An HTTP request whose head we have in full. `head_len` is one past the
|
||||||
|
/// blank line; `buffer` may hold pipelined body bytes beyond it.
|
||||||
|
HttpRequest { buffer: Vec<u8>, head_len: usize },
|
||||||
|
/// Not HTTP, or HTTP we gave up on reading. Forwarded verbatim, ungated.
|
||||||
|
Opaque(Vec<u8>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LeadingBytes {
|
||||||
|
fn into_buffer(self) -> Vec<u8> {
|
||||||
|
match self {
|
||||||
|
LeadingBytes::HttpRequest { buffer, .. } => buffer,
|
||||||
|
LeadingBytes::Opaque(buffer) => buffer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read just enough of the connection to classify it, without consuming
|
||||||
|
/// anything the caller cannot replay.
|
||||||
|
///
|
||||||
|
/// Bails out to [`LeadingBytes::Opaque`] the moment the first line proves this
|
||||||
|
/// is not HTTP, so a non-HTTP protocol pays one line of latency and no more.
|
||||||
|
/// The only hard failure is silence: a client that sends nothing within
|
||||||
|
/// [`FIRST_BYTE_TIMEOUT`] is dropped before an exec is spent on it.
|
||||||
|
async fn read_leading_bytes(stream: &mut TcpStream) -> Result<LeadingBytes, String> {
|
||||||
|
let mut buf: Vec<u8> = Vec::with_capacity(1024);
|
||||||
|
let mut chunk = [0u8; 1024];
|
||||||
|
let mut deadline = tokio::time::Instant::now() + FIRST_BYTE_TIMEOUT;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = match tokio::time::timeout_at(deadline, stream.read(&mut chunk)).await {
|
||||||
|
Ok(Ok(0)) if buf.is_empty() => {
|
||||||
|
return Err("closed before sending anything".to_string())
|
||||||
|
}
|
||||||
|
// A half-close after some bytes is legitimate; forward what we have.
|
||||||
|
Ok(Ok(0)) => return Ok(LeadingBytes::Opaque(buf)),
|
||||||
|
Ok(Ok(n)) => n,
|
||||||
|
Ok(Err(e)) => return Err(format!("read failed: {}", e)),
|
||||||
|
Err(_) if buf.is_empty() => {
|
||||||
|
return Err(format!(
|
||||||
|
"sent nothing within {}s",
|
||||||
|
FIRST_BYTE_TIMEOUT.as_secs()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// Bytes arrived but the head never finished. Fail open: this is a
|
||||||
|
// gate on top of the bridge, not the bridge's reason to exist.
|
||||||
|
Err(_) => return Ok(LeadingBytes::Opaque(buf)),
|
||||||
|
};
|
||||||
|
buf.extend_from_slice(&chunk[..n]);
|
||||||
|
|
||||||
|
// Once the first line is complete we know whether to keep reading.
|
||||||
|
if let Some(eol) = buf.iter().position(|b| *b == b'\n') {
|
||||||
|
if !is_http_request_line(&buf[..eol]) {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
deadline = deadline.max(tokio::time::Instant::now() + HEAD_TIMEOUT);
|
||||||
|
} else if buf.len() > MAX_HEAD {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(head_len) = find_head_end(&buf) {
|
||||||
|
return Ok(LeadingBytes::HttpRequest {
|
||||||
|
buffer: buf,
|
||||||
|
head_len,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if buf.len() > MAX_HEAD {
|
||||||
|
return Ok(LeadingBytes::Opaque(buf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a first line looks like `METHOD target HTTP/1.x`.
|
||||||
|
fn is_http_request_line(line: &[u8]) -> bool {
|
||||||
|
let line = String::from_utf8_lossy(line);
|
||||||
|
let line = line.trim_end_matches(['\r', '\n']);
|
||||||
|
let mut parts = line.split(' ');
|
||||||
|
let (Some(method), Some(target), Some(version), None) =
|
||||||
|
(parts.next(), parts.next(), parts.next(), parts.next())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
!method.is_empty()
|
||||||
|
&& method.chars().all(|c| c.is_ascii_uppercase())
|
||||||
|
&& !target.is_empty()
|
||||||
|
&& (version == "HTTP/1.1" || version == "HTTP/1.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index just past the blank line terminating an HTTP head, if it has arrived.
|
||||||
|
/// Tolerates a bare-LF terminator, which some minimal clients still emit.
|
||||||
|
fn find_head_end(buf: &[u8]) -> Option<usize> {
|
||||||
|
buf.windows(4)
|
||||||
|
.position(|w| w == b"\r\n\r\n")
|
||||||
|
.map(|i| i + 4)
|
||||||
|
.or_else(|| buf.windows(2).position(|w| w == b"\n\n").map(|i| i + 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell a refused caller why, then close. Plain text and `Connection: close` —
|
||||||
|
/// there is no session here to keep alive.
|
||||||
|
async fn refuse(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||||
|
const BODY: &str = "This port is bridged from a container by Triple-C for a sign-in \
|
||||||
|
callback. It is not an API for web pages to call.\n";
|
||||||
|
let response = format!(
|
||||||
|
"HTTP/1.1 403 Forbidden\r\n\
|
||||||
|
Content-Type: text/plain; charset=utf-8\r\n\
|
||||||
|
Content-Length: {}\r\n\
|
||||||
|
Cache-Control: no-store\r\n\
|
||||||
|
Connection: close\r\n\r\n{}",
|
||||||
|
BODY.len(),
|
||||||
|
BODY
|
||||||
|
);
|
||||||
|
stream.write_all(response.as_bytes()).await?;
|
||||||
|
stream.shutdown().await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// The gate — pure, so it can be tested without sockets
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Verdict {
|
||||||
|
/// Forward it. Either it is not a browser, or the browser says this is a
|
||||||
|
/// navigation or a same-origin request.
|
||||||
|
Allow,
|
||||||
|
/// Fetch metadata says a document on another site pulled this in as a
|
||||||
|
/// sub-resource. No login flow looks like that.
|
||||||
|
RefuseCrossSite,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether an HTTP request head arriving on a bridged port may be
|
||||||
|
/// forwarded into the container.
|
||||||
|
///
|
||||||
|
/// Deliberately fail-open — see the module docs for exactly what that leaves
|
||||||
|
/// uncovered. The only refusal is the case with no innocent reading:
|
||||||
|
/// `Sec-Fetch-Site` says another site, and `Sec-Fetch-Mode` says this is not a
|
||||||
|
/// navigation. `Sec-Fetch-*` are forbidden header names, so page script cannot
|
||||||
|
/// set or clear them.
|
||||||
|
pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||||
|
let mut lines = head.split(['\r', '\n']).filter(|l| !l.is_empty());
|
||||||
|
// Skip the request line.
|
||||||
|
if lines.next().is_none() {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut site: Option<&str> = None;
|
||||||
|
let mut mode: Option<&str> = None;
|
||||||
|
for line in lines {
|
||||||
|
let Some((name, value)) = line.split_once(':') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = value.trim();
|
||||||
|
match name.trim().to_ascii_lowercase().as_str() {
|
||||||
|
// A duplicate of either header is header smuggling, not a client.
|
||||||
|
// Refuse rather than pick a winner: last-occurrence-wins is what
|
||||||
|
// turns a smuggling primitive into a bypass.
|
||||||
|
"sec-fetch-site" if site.is_some() => return Verdict::RefuseCrossSite,
|
||||||
|
"sec-fetch-mode" if mode.is_some() => return Verdict::RefuseCrossSite,
|
||||||
|
"sec-fetch-site" => site = Some(value),
|
||||||
|
"sec-fetch-mode" => mode = Some(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(site) = site else {
|
||||||
|
// No fetch metadata: a CLI, `curl`, or a browser old enough not to send
|
||||||
|
// it. Not something this gate can judge.
|
||||||
|
return Verdict::Allow;
|
||||||
|
};
|
||||||
|
if site.eq_ignore_ascii_case("same-origin") || site.eq_ignore_ascii_case("none") {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
// `navigate` is precisely the OAuth redirect: the provider sends the browser
|
||||||
|
// to `http://localhost:<port>/callback`, cross-site, as a document load.
|
||||||
|
// Refusing it would refuse the feature.
|
||||||
|
if mode.is_none_or(|m| m.eq_ignore_ascii_case("navigate")) {
|
||||||
|
return Verdict::Allow;
|
||||||
|
}
|
||||||
|
Verdict::RefuseCrossSite
|
||||||
}
|
}
|
||||||
|
|
||||||
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
/// As [`tunnel_connection`], but `prelude` is written into the container first,
|
||||||
@@ -250,21 +553,36 @@ pub async fn tunnel_connection_with_prelude(
|
|||||||
}
|
}
|
||||||
let mut buf = vec![0u8; PUMP_BUF];
|
let mut buf = vec![0u8; PUMP_BUF];
|
||||||
loop {
|
loop {
|
||||||
match host_rx.read(&mut buf).await {
|
// Idle-bounded. Without this a client that connects, sends a
|
||||||
Ok(0) => break,
|
// request and then never speaks or closes holds the exec open for
|
||||||
Ok(n) => {
|
// as long as the container runs.
|
||||||
|
match tokio::time::timeout(IDLE_TIMEOUT, host_rx.read(&mut buf)).await {
|
||||||
|
Ok(Ok(0)) | Err(_) => break,
|
||||||
|
Ok(Ok(n)) => {
|
||||||
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
Ok(Err(_)) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Container → host. This direction is authoritative: when the exec's output
|
// Container → host. This direction is authoritative: when the exec's output
|
||||||
// stream ends, socat has exited and the connection is over.
|
// stream ends, socat has exited and the connection is over. It is also the
|
||||||
while let Some(chunk) = output.next().await {
|
// one that decides the connection is dead: nothing back from the container
|
||||||
|
// for `IDLE_TIMEOUT` tears the whole thing down, exec included.
|
||||||
|
while let Some(chunk) = match tokio::time::timeout(IDLE_TIMEOUT, output.next()).await {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(_) => {
|
||||||
|
log::debug!(
|
||||||
|
"Auth bridge: bridged port {} idle for {}s — closing the tunnel",
|
||||||
|
port,
|
||||||
|
IDLE_TIMEOUT.as_secs()
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} {
|
||||||
match chunk {
|
match chunk {
|
||||||
// Only stdout is payload. The exec is created with tty = false
|
// Only stdout is payload. The exec is created with tty = false
|
||||||
// precisely so Docker demultiplexes these, keeping socat's stderr
|
// precisely so Docker demultiplexes these, keeping socat's stderr
|
||||||
@@ -293,3 +611,218 @@ pub async fn tunnel_connection_with_prelude(
|
|||||||
// Explicit: stop reading from the host now that the container side is gone.
|
// Explicit: stop reading from the host now that the container side is gone.
|
||||||
drop(upstream);
|
drop(upstream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn head(lines: &[&str]) -> String {
|
||||||
|
format!("{}\r\n\r\n", lines.join("\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cli_callback_with_no_fetch_metadata_is_forwarded() {
|
||||||
|
// The overwhelmingly common case, and the reason the gate fails open:
|
||||||
|
// `curl`, a CLI's own probe, and anything not a browser send none of
|
||||||
|
// these headers, and none of them can be judged from the wire.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /callback?code=abc HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"User-Agent: curl/8.5.0",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_oauth_redirect_is_forwarded_even_though_it_is_cross_site() {
|
||||||
|
// This is the feature. The provider bounces the browser to
|
||||||
|
// `http://localhost:<port>/callback`, which is cross-site and a
|
||||||
|
// navigation. Refusing it would refuse every login the bridge exists
|
||||||
|
// for.
|
||||||
|
for site in ["cross-site", "same-site"] {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /callback?code=abc&state=xyz HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
&format!("Sec-Fetch-Site: {}", site),
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
"Sec-Fetch-Dest: document",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow, "site={}", site);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_form_post_callback_is_forwarded() {
|
||||||
|
// `response_mode=form_post` providers POST the callback as a
|
||||||
|
// navigation. Still a navigation, still allowed.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"POST /callback HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Origin: https://login.microsoftonline.com",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_cross_site_subresource_from_a_web_page_is_refused() {
|
||||||
|
// The case the gate exists for: a page the user happens to have open
|
||||||
|
// scanning loopback and poking whatever answers.
|
||||||
|
for mode in ["cors", "no-cors", "same-origin", "websocket"] {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /admin HTTP/1.1",
|
||||||
|
"Host: 127.0.0.1:41733",
|
||||||
|
"Origin: https://evil.example",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
&format!("Sec-Fetch-Mode: {}", mode),
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::RefuseCrossSite, "mode={}", mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_containers_own_same_origin_requests_are_forwarded() {
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /style.css HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Sec-Fetch-Site: same-origin",
|
||||||
|
"Sec-Fetch-Mode: no-cors",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
// `none` is a user-initiated load — typed URL, bookmark.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET / HTTP/1.1",
|
||||||
|
"Host: localhost:41733",
|
||||||
|
"Sec-Fetch-Site: none",
|
||||||
|
"Sec-Fetch-Mode: navigate",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::Allow);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicated_fetch_metadata_is_refused_rather_than_resolved() {
|
||||||
|
// Last-occurrence-wins is what turns any header-smuggling primitive
|
||||||
|
// into a bypass, and no real client sends two.
|
||||||
|
let verdict = web_request_verdict(&head(&[
|
||||||
|
"GET /x HTTP/1.1",
|
||||||
|
"Sec-Fetch-Site: cross-site",
|
||||||
|
"Sec-Fetch-Mode: cors",
|
||||||
|
"Sec-Fetch-Site: same-origin",
|
||||||
|
]));
|
||||||
|
assert_eq!(verdict, Verdict::RefuseCrossSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_head_is_ever_judged() {
|
||||||
|
// A cross-site `text/plain` POST is not preflighted, so its *body* is
|
||||||
|
// fully attacker-chosen. `tunnel_connection` slices at the blank line
|
||||||
|
// before calling in; this pins that the slice is what gets judged.
|
||||||
|
let raw = "POST /x HTTP/1.1\r\n\
|
||||||
|
Sec-Fetch-Site: cross-site\r\n\
|
||||||
|
Sec-Fetch-Mode: cors\r\n\
|
||||||
|
Content-Type: text/plain\r\n\r\n\
|
||||||
|
Sec-Fetch-Site: same-origin\r\n";
|
||||||
|
let head_len = find_head_end(raw.as_bytes()).expect("head terminator");
|
||||||
|
let head = &raw[..head_len];
|
||||||
|
assert!(!head.contains("same-origin"), "the forged line must be past the slice");
|
||||||
|
assert_eq!(web_request_verdict(head), Verdict::RefuseCrossSite);
|
||||||
|
|
||||||
|
// And if the slice were ever got wrong, the duplicate rule is the
|
||||||
|
// backstop: a forged `Sec-Fetch-*` line is by construction a second
|
||||||
|
// copy of one the browser already sent, which is refused outright
|
||||||
|
// rather than resolved in the forgery's favour.
|
||||||
|
assert_eq!(web_request_verdict(raw), Verdict::RefuseCrossSite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_http_first_line_is_never_treated_as_a_request() {
|
||||||
|
// Bridged ports are not all HTTP. Anything whose first line is not a
|
||||||
|
// request line is forwarded verbatim rather than parsed.
|
||||||
|
assert!(!is_http_request_line(b"\x16\x03\x01\x02\x00\x01"));
|
||||||
|
assert!(!is_http_request_line(b"*1\r"));
|
||||||
|
assert!(!is_http_request_line(b"SSH-2.0-OpenSSH_9.6"));
|
||||||
|
assert!(!is_http_request_line(b"GET /x HTTP/2.0"));
|
||||||
|
assert!(!is_http_request_line(b"get /x HTTP/1.1"));
|
||||||
|
assert!(is_http_request_line(b"GET /x HTTP/1.1\r"));
|
||||||
|
assert!(is_http_request_line(b"POST /callback?code=a%20b HTTP/1.0"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn head_end_is_found_for_both_terminators() {
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\n\r\nBODY"), Some(18));
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\n\nBODY"), Some(16));
|
||||||
|
assert_eq!(find_head_end(b"GET / HTTP/1.1\r\nHost: x\r\n"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_client_that_says_nothing_never_costs_a_container_exec() {
|
||||||
|
// Every accepted connection would otherwise spawn a `docker exec`
|
||||||
|
// immediately, so silence was free for the caller and expensive here.
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let _client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
let started = tokio::time::Instant::now();
|
||||||
|
let result = accept.await.expect("join");
|
||||||
|
|
||||||
|
assert!(result.is_err(), "silence should not be forwarded");
|
||||||
|
assert!(
|
||||||
|
started.elapsed() >= FIRST_BYTE_TIMEOUT,
|
||||||
|
"should have waited out the first-byte grace period"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_non_http_client_is_classified_from_its_first_line_alone() {
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
client.write_all(b"SSH-2.0-OpenSSH_9.6\r\n").await.expect("write");
|
||||||
|
|
||||||
|
let result = accept.await.expect("join").expect("classified");
|
||||||
|
// Verbatim, and without waiting for a head terminator that will never
|
||||||
|
// come — the whole buffer is replayed into the tunnel.
|
||||||
|
assert!(matches!(result, LeadingBytes::Opaque(_)));
|
||||||
|
assert_eq!(result.into_buffer(), b"SSH-2.0-OpenSSH_9.6\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_http_head_is_read_whole_and_replayed_whole() {
|
||||||
|
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||||
|
.await
|
||||||
|
.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
let accept = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.expect("accept");
|
||||||
|
read_leading_bytes(&mut stream).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let raw = b"POST /callback HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\n\r\ncode";
|
||||||
|
let mut client = TcpStream::connect(addr).await.expect("connect");
|
||||||
|
client.write_all(raw).await.expect("write");
|
||||||
|
|
||||||
|
let result = accept.await.expect("join").expect("classified");
|
||||||
|
match &result {
|
||||||
|
LeadingBytes::HttpRequest { buffer, head_len } => {
|
||||||
|
assert_eq!(&buffer[*head_len..], b"code", "body must survive the peek");
|
||||||
|
assert!(!buffer[..*head_len].ends_with(b"code"));
|
||||||
|
}
|
||||||
|
LeadingBytes::Opaque(_) => panic!("should have been recognised as HTTP"),
|
||||||
|
}
|
||||||
|
assert_eq!(result.into_buffer(), raw.to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -304,21 +304,7 @@ impl BrowserViewManager {
|
|||||||
// a container with no dashboard makes this a no-op.
|
// a container with no dashboard makes this a no-op.
|
||||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||||
|
|
||||||
let container_port = pick_viewer_port(&container_id).await?;
|
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).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 token = generate_token();
|
let token = generate_token();
|
||||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
// `--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
|
// Readiness, ports, URLs
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on.
|
/// How many free ports a start will try before giving up.
|
||||||
async fn pick_viewer_port(container_id: &str) -> Result<u16, String> {
|
///
|
||||||
|
/// 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(
|
let text = exec_oneshot(
|
||||||
container_id,
|
container_id,
|
||||||
vec![
|
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/tcp".to_string(),
|
||||||
"/proc/net/tcp6".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);
|
let taken = proc_net::parse_loopback_listeners(&text);
|
||||||
VIEWER_PORTS
|
VIEWER_PORTS
|
||||||
.clone()
|
.clone()
|
||||||
.find(|p| !taken.contains_key(p))
|
.find(|p| !taken.contains_key(p) && !tried.contains(p))
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
format!(
|
format!(
|
||||||
"No free port in {}–{} inside the container for the Playwright viewer.",
|
"No free port in {}–{} inside the container for the Playwright viewer.",
|
||||||
|
|||||||
@@ -3,11 +3,78 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
|
<!--
|
||||||
|
This page is served by an axum server bound 0.0.0.0 (remote access is the
|
||||||
|
feature) behind a permissive CORS layer, and it fronts a shell in a container.
|
||||||
|
It gets a CSP of its own because nothing else gives it one: the app's
|
||||||
|
`tauri.conf.json` CSP covers the desktop webview, never this document.
|
||||||
|
|
||||||
|
`default-src 'none'` is the base, so anything not named below is refused
|
||||||
|
outright. What is named:
|
||||||
|
script-src jsdelivr for the three xterm bundles, plus 'unsafe-inline' for
|
||||||
|
this page's own inline <script>. Nonces/hashes were considered
|
||||||
|
and rejected: the file is a static `include_str!()` asset, so a
|
||||||
|
hash would have to be recomputed by hand on every edit to the
|
||||||
|
script, and the failure mode of getting that wrong is a terminal
|
||||||
|
that silently will not start.
|
||||||
|
style-src the xterm stylesheet, this page's <style>, and the one inline
|
||||||
|
`style=` attribute below (style attributes need 'unsafe-inline').
|
||||||
|
connect-src the WebSocket back to this same server. `ws:`/`wss:` as schemes
|
||||||
|
rather than an origin, because the host and port are whatever
|
||||||
|
the user reached this page on and are not knowable at build time.
|
||||||
|
form-action / base-uri / object-src / frame-ancestors — all 'none'. Note
|
||||||
|
`frame-ancestors` is ignored in a <meta> CSP; it is here as a
|
||||||
|
statement of intent, and the real protection would be a response
|
||||||
|
header from `server.rs`.
|
||||||
|
Deliberately absent: 'unsafe-eval', and any origin other than jsdelivr.
|
||||||
|
-->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="
|
||||||
|
default-src 'none';
|
||||||
|
script-src 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||||
|
style-src 'unsafe-inline' https://cdn.jsdelivr.net;
|
||||||
|
img-src 'self' data:;
|
||||||
|
font-src 'self' data:;
|
||||||
|
connect-src 'self' ws: wss:;
|
||||||
|
form-action 'none';
|
||||||
|
base-uri 'none';
|
||||||
|
object-src 'none';
|
||||||
|
frame-ancestors 'none';
|
||||||
|
">
|
||||||
<title>Triple-C Web Terminal</title>
|
<title>Triple-C Web Terminal</title>
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
<!--
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
Subresource Integrity on every CDN asset.
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
Without it this page executes whatever jsdelivr returns, inside a document
|
||||||
|
that holds the web terminal's access token and drives a shell in a container —
|
||||||
|
an upstream compromise, a hijacked package version or a MITM on a phone's
|
||||||
|
network is arbitrary code with that reach. The hashes below were computed
|
||||||
|
from the exact bytes at these pinned versions. `crossorigin="anonymous"` is
|
||||||
|
required for SRI to be checked on a cross-origin fetch.
|
||||||
|
|
||||||
|
Bumping a version means recomputing its hash:
|
||||||
|
curl -sS <url> | openssl dgst -sha384 -binary | openssl base64 -A
|
||||||
|
A mismatched hash blocks the asset, so a stale hash shows up immediately as a
|
||||||
|
terminal that does not render — never as an unverified load.
|
||||||
|
|
||||||
|
These are still remote loads: the remote terminal does not work with no
|
||||||
|
internet on the client side, and vendoring the ~300 KB of minified xterm into
|
||||||
|
this file would fix that. It was not done here — SRI already closes the
|
||||||
|
integrity half, which is the security half, and the availability half is a
|
||||||
|
separate call about binary size and diff readability.
|
||||||
|
-->
|
||||||
|
<link rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css"
|
||||||
|
integrity="sha384-tStR1zLfWgsiXCF3IgfB3lBa8KmBe/lG287CL9WCeKgQYcp1bjb4/+mwN6oti4Co"
|
||||||
|
crossorigin="anonymous">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"
|
||||||
|
integrity="sha384-J4qzUjBl1FxyLsl/kQPQIOeINsmp17OHYXDOMpMxlKX53ZfYsL+aWHpgArvOuof9"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"
|
||||||
|
integrity="sha384-XGqKrV8Jrukp1NITJbOEHwg01tNkuXr6uB6YEj69ebpYU3v7FvoGgEg23C1Gcehk"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"
|
||||||
|
integrity="sha384-S1biLeI8L/bFduIVvCxbn/l4EtaG4nTqQjGF7qCYTbsGXGFe8KgIKXtw4+UWxprv"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #1a1b26;
|
--bg-primary: #1a1b26;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827"
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; frame-src http://127.0.0.1:47820 http://127.0.0.1:47821 http://127.0.0.1:47822 http://127.0.0.1:47823 http://127.0.0.1:47824 http://127.0.0.1:47825 http://127.0.0.1:47826 http://127.0.0.1:47827; form-action 'none'; base-uri 'none'; object-src 'none'"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderMarkdown } from "./HelpDialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `renderMarkdown` builds HTML by regex substitution and the result is handed
|
||||||
|
* to `dangerouslySetInnerHTML`. Its input is the help document, which is
|
||||||
|
* fetched from GitHub at runtime — remote, versioned by someone else, and not
|
||||||
|
* something the app gets to trust. These tests pin the escaping.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Parse rendered HTML and return its first anchor, asserting that *no* element
|
||||||
|
* anywhere in the output grew an attribute outside the allowed set. A broken
|
||||||
|
* attribute value is only interesting if it becomes an attribute, so the check
|
||||||
|
* has to run through a real parser rather than over the string.
|
||||||
|
*/
|
||||||
|
const ALLOWED_ATTRS = new Set(["class", "href", "target", "rel", "id"]);
|
||||||
|
|
||||||
|
function onlyAnchor(html: string): HTMLAnchorElement {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
for (const el of Array.from(doc.body.querySelectorAll("*"))) {
|
||||||
|
for (const name of attrNames(el)) {
|
||||||
|
expect(ALLOWED_ATTRS.has(name), `unexpected attribute ${name}`).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const anchors = doc.querySelectorAll("a");
|
||||||
|
expect(anchors.length).toBeGreaterThan(0);
|
||||||
|
return anchors[0] as HTMLAnchorElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attribute names the parser actually saw on an element. */
|
||||||
|
function attrNames(el: Element): string[] {
|
||||||
|
return Array.from(el.attributes).map((a) => a.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderMarkdown escaping", () => {
|
||||||
|
it("escapes the quote characters an attribute value is delimited by", () => {
|
||||||
|
const html = renderMarkdown('He said "hi" and it\'s fine.');
|
||||||
|
expect(html).not.toMatch(/said "hi"/);
|
||||||
|
expect(html).toContain(""hi"");
|
||||||
|
expect(html).toContain("it's");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a link target break out of href=\"…\"", () => {
|
||||||
|
// The sink: the URL capture is `[^)]+`, which includes `"` and spaces, and
|
||||||
|
// the value lands directly inside `href="…"`. Asserted through the DOM,
|
||||||
|
// not by string matching — the payload text legitimately survives *inside*
|
||||||
|
// the attribute value; what must not happen is it becoming an attribute.
|
||||||
|
const a = onlyAnchor(
|
||||||
|
renderMarkdown(
|
||||||
|
'[click](https://example.com/" onmouseover="steal() formaction="https://evil.example)',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(attrNames(a)).toEqual(["class", "href", "target", "rel"]);
|
||||||
|
expect(a.getAttribute("href")).toContain('" onmouseover="');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let an in-document anchor break out of href=\"#…\"", () => {
|
||||||
|
const a = onlyAnchor(
|
||||||
|
renderMarkdown('[jump](#top" onfocus="steal() autofocus="x)'),
|
||||||
|
);
|
||||||
|
expect(attrNames(a)).toEqual(["class", "href"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a bare URL break out of href=\"…\"", () => {
|
||||||
|
const a = onlyAnchor(
|
||||||
|
renderMarkdown('See https://example.com/a"onmouseover="steal()\n'),
|
||||||
|
);
|
||||||
|
expect(attrNames(a)).toEqual(["class", "href", "target", "rel"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders ordinary links intact", () => {
|
||||||
|
const html = renderMarkdown("[docs](https://example.com/a?x=1&y=2)");
|
||||||
|
// `&` was entity-escaped by the first pass and must not be escaped twice.
|
||||||
|
expect(html).toContain('href="https://example.com/a?x=1&y=2"');
|
||||||
|
expect(html).not.toContain("&amp;");
|
||||||
|
expect(html).toContain('target="_blank"');
|
||||||
|
expect(html).toContain('rel="noopener noreferrer"');
|
||||||
|
expect(html).toContain(">docs</a>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders an in-document anchor link intact", () => {
|
||||||
|
const html = renderMarkdown("[jump](#getting-started)");
|
||||||
|
expect(html).toContain('href="#getting-started"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps header slugs stable across the new quote escaping", () => {
|
||||||
|
// The regression this guards: quotes now become entities *before*
|
||||||
|
// `slugify` sees them, and an entity's letters would otherwise survive
|
||||||
|
// into the id ("claude39s-setup"), silently breaking every
|
||||||
|
// `[…](#claudes-setup)` in the document.
|
||||||
|
expect(renderMarkdown("## Claude's setup")).toContain('id="claudes-setup"');
|
||||||
|
expect(renderMarkdown('## The "safe" mode')).toContain('id="the-safe-mode"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still refuses to emit raw tags from the source document", () => {
|
||||||
|
const html = renderMarkdown("<img src=x onerror=alert(1)>");
|
||||||
|
expect(html).not.toContain("<img");
|
||||||
|
expect(html).toContain("<img");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,21 +12,67 @@ function slugify(text: string): string {
|
|||||||
return text
|
return text
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
|
||||||
|
// Quote characters are escaped to entities before this runs (see
|
||||||
|
// `renderMarkdown`). Drop those two entities whole, so a header with an
|
||||||
|
// apostrophe or a quote slugifies to what it did when the character was
|
||||||
|
// simply stripped — otherwise every such anchor id silently changes and
|
||||||
|
// the in-document links pointing at it stop resolving. `&`/`<`/
|
||||||
|
// `>` are deliberately not in this list: they were already entities
|
||||||
|
// before, so their existing (odd) slugs are the established ones.
|
||||||
|
.replace(/"|'/g, "")
|
||||||
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
|
||||||
.replace(/\s+/g, "-") // spaces to dashes
|
.replace(/\s+/g, "-") // spaces to dashes
|
||||||
.replace(/-+/g, "-") // collapse consecutive dashes
|
.replace(/-+/g, "-") // collapse consecutive dashes
|
||||||
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Simple markdown-to-HTML converter for the help content. */
|
/**
|
||||||
function renderMarkdown(md: string): string {
|
* Escape a captured markdown value that is about to be interpolated into an
|
||||||
|
* HTML *attribute* value.
|
||||||
|
*
|
||||||
|
* `renderMarkdown` entity-escapes the whole document first, but that pass only
|
||||||
|
* covered `&`, `<` and `>` — not the quote characters, which is all an
|
||||||
|
* attribute value is delimited by. `[x](https://a" onload="…)` therefore closed
|
||||||
|
* `href="` and started a new attribute, because the URL capture is `[^)]+` and
|
||||||
|
* `"` is in `[^)]`. The document is remote GitHub markdown, so that capture is
|
||||||
|
* not ours to trust.
|
||||||
|
*
|
||||||
|
* Only quotes are escaped here: `&`, `<` and `>` have already been converted by
|
||||||
|
* the caller, and re-escaping the `&` would double-encode every `&` in a
|
||||||
|
* query string.
|
||||||
|
*/
|
||||||
|
function attr(value: string): string {
|
||||||
|
return value.replace(/"/g, """).replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple markdown-to-HTML converter for the help content.
|
||||||
|
*
|
||||||
|
* Exported for `HelpDialog.test.tsx`: the output goes to
|
||||||
|
* `dangerouslySetInnerHTML`, so the escaping rules below are security rules and
|
||||||
|
* need to be asserted rather than assumed.
|
||||||
|
*/
|
||||||
|
export function renderMarkdown(md: string): string {
|
||||||
let html = md;
|
let html = md;
|
||||||
|
|
||||||
// Normalize line endings
|
// Normalize line endings
|
||||||
html = html.replace(/\r\n/g, "\n");
|
html = html.replace(/\r\n/g, "\n");
|
||||||
|
|
||||||
// Escape HTML entities (but we'll re-introduce tags below)
|
// Escape HTML entities (but we'll re-introduce tags below).
|
||||||
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
//
|
||||||
|
// The quote characters are part of this on purpose. Everything below builds
|
||||||
|
// HTML by regex substitution, and several of those substitutions drop a
|
||||||
|
// capture straight into an attribute value (`href="$2"`). Leaving `"` and `'`
|
||||||
|
// live meant a link target could close the attribute and open another one —
|
||||||
|
// in a document fetched from GitHub at runtime and handed to
|
||||||
|
// `dangerouslySetInnerHTML`. Escaping here closes every such sink at the
|
||||||
|
// source; `attr()` below is the belt to this pair of braces.
|
||||||
|
html = html
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
// Fenced code blocks (```...```)
|
// Fenced code blocks (```...```)
|
||||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||||
@@ -84,13 +130,15 @@ function renderMarkdown(md: string): string {
|
|||||||
// Markdown-style anchor links [text](#anchor)
|
// Markdown-style anchor links [text](#anchor)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
/\[([^\]]+)\]\(#([^)]+)\)/g,
|
||||||
'<a class="help-link" href="#$2">$1</a>',
|
(_m, text: string, anchor: string) =>
|
||||||
|
`<a class="help-link" href="#${attr(anchor)}">${text}</a>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Markdown-style external links [text](url)
|
// Markdown-style external links [text](url)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
|
||||||
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
(_m, text: string, url: string) =>
|
||||||
|
`<a class="help-link" href="${attr(url)}" target="_blank" rel="noopener noreferrer">${text}</a>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Unordered list items (- ...)
|
// Unordered list items (- ...)
|
||||||
@@ -117,7 +165,8 @@ function renderMarkdown(md: string): string {
|
|||||||
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
|
||||||
html = html.replace(
|
html = html.replace(
|
||||||
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
||||||
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
|
(_m, url: string) =>
|
||||||
|
`<a class="help-link" href="${attr(url)}" target="_blank" rel="noopener noreferrer">${url}</a>`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wrap remaining loose text lines in paragraphs
|
// Wrap remaining loose text lines in paragraphs
|
||||||
|
|||||||
@@ -457,6 +457,35 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
// host-side gate checks before anything reaches the container.
|
// host-side gate checks before anything reaches the container.
|
||||||
src={status.url ?? undefined}
|
src={status.url ?? undefined}
|
||||||
title={`Playwright browser view for ${project.name}`}
|
title={`Playwright browser view for ${project.name}`}
|
||||||
|
// What is framed here is served by a process inside the container,
|
||||||
|
// which is the untrusted side of this app. Unsandboxed, it could
|
||||||
|
// simply set `top.location` and navigate the *app's* webview
|
||||||
|
// somewhere of its choosing — the frame is cross-origin, so it cannot
|
||||||
|
// read the app, but steering the whole window is not something a
|
||||||
|
// viewer pane should be able to do.
|
||||||
|
//
|
||||||
|
// The allowances are what the Playwright dashboard actually needs and
|
||||||
|
// no more:
|
||||||
|
// allow-scripts — it is an application, not a document.
|
||||||
|
// allow-same-origin — it must reach its own WebSocket and assets,
|
||||||
|
// and the host-side gate recognises the pane's
|
||||||
|
// own sub-resource requests by their
|
||||||
|
// `Origin`/`Referer`; an opaque origin would
|
||||||
|
// send `null` and be refused. This does not
|
||||||
|
// grant access to *this* app: 127.0.0.1:4782x
|
||||||
|
// is a different origin from the app's.
|
||||||
|
// allow-forms/-modals/-downloads/-popups — dashboard UI affordances
|
||||||
|
// (trace download, confirm dialogs, opening a
|
||||||
|
// page in a new window).
|
||||||
|
//
|
||||||
|
// Deliberately absent, and the point of the attribute:
|
||||||
|
// `allow-top-navigation`, `allow-top-navigation-by-user-activation`
|
||||||
|
// and `allow-popups-to-escape-sandbox`. Do not add them.
|
||||||
|
//
|
||||||
|
// No `referrerPolicy` either: the gate in `browser_view/proxy.rs`
|
||||||
|
// reads the token out of a same-origin `Referer`, so stripping it
|
||||||
|
// would break the pane.
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-downloads allow-popups"
|
||||||
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
className="flex-1 min-h-0 w-full border-0 bg-[var(--bg-primary)]"
|
||||||
/>
|
/>
|
||||||
) : live ? (
|
) : live ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user