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:
@@ -13,8 +13,48 @@
|
||||
//! The exec plumbing itself is *not* reimplemented here: it comes from
|
||||
//! [`crate::docker::exec::create_attached_exec`], the same helper the
|
||||
//! 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::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::StreamExt;
|
||||
@@ -30,6 +70,38 @@ use super::proc_net::PortFamily;
|
||||
/// only needs to not be pathological.
|
||||
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
|
||||
/// child running.
|
||||
struct AbortOnDrop(JoinHandle<()>);
|
||||
@@ -167,6 +239,22 @@ async fn accept_loop(
|
||||
|
||||
match accepted {
|
||||
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);
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(tunnel_connection(
|
||||
@@ -195,9 +283,224 @@ async fn accept_optional(
|
||||
}
|
||||
}
|
||||
|
||||
/// Carry one accepted host connection into the container over `socat`.
|
||||
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
|
||||
tunnel_connection_with_prelude(container_id, target, stream, port, Vec::new()).await
|
||||
/// Carry one accepted host connection into the container over `socat`, after
|
||||
/// deciding it is not a web page reaching into loopback.
|
||||
///
|
||||
/// 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,
|
||||
@@ -250,21 +553,36 @@ pub async fn tunnel_connection_with_prelude(
|
||||
}
|
||||
let mut buf = vec![0u8; PUMP_BUF];
|
||||
loop {
|
||||
match host_rx.read(&mut buf).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
// Idle-bounded. Without this a client that connects, sends a
|
||||
// request and then never speaks or closes holds the exec open for
|
||||
// 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() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
Ok(Err(_)) => break,
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Container → host. This direction is authoritative: when the exec's output
|
||||
// stream ends, socat has exited and the connection is over.
|
||||
while let Some(chunk) = output.next().await {
|
||||
// stream ends, socat has exited and the connection is over. It is also the
|
||||
// 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 {
|
||||
// Only stdout is payload. The exec is created with tty = false
|
||||
// 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.
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user