Make dashboard work from proxied origins via same-origin proxy (#1111)

* Restore dashboard session proxy routes

Change-Id: I36ffc3727ce44100121bc94a81510a5f009ee0bc
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* Port dashboard frontend and docs

Change-Id: I80356f64d618dab9d07b610ba67def14539f98ac
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* docs: restore dashboard note in skill

Change-Id: Id0913c64e7a6f2cbbfc429ef03b34dae185d8487
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* fix: tighten dashboard proxy same-origin checks

Change-Id: I792bc859a24cd47314bd46c94344ef3dfb7d6db5
Signed-off-by: Thomas Kosiewski <tk@coder.com>

---------

Signed-off-by: Thomas Kosiewski <tk@coder.com>
This commit is contained in:
Thomas Kosiewski
2026-05-07 09:08:12 -05:00
committed by GitHub
parent 3bb1d43f8b
commit d33bdb36f3
10 changed files with 776 additions and 54 deletions
+705 -40
View File
@@ -1,7 +1,8 @@
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio::io::AsyncWriteExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message;
use crate::connection::get_socket_dir;
@@ -9,6 +10,418 @@ use super::chat::{chat_status_json, handle_chat_request, handle_models_request};
use super::discovery::discover_sessions;
use super::http::{serve_embedded_file, CORS_HEADERS};
/// Dashboard same-origin proxy endpoints for session metadata and streams.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionProxyEndpoint {
Tabs,
Status,
Stream,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct DashboardProxyError {
status: &'static str,
message: String,
}
impl DashboardProxyError {
fn not_found(message: impl Into<String>) -> Self {
Self {
status: "404 Not Found",
message: message.into(),
}
}
fn bad_gateway(message: impl Into<String>) -> Self {
Self {
status: "502 Bad Gateway",
message: message.into(),
}
}
}
const PROXY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const PROXY_MAX_RESPONSE_SIZE: u64 = 16 * 1024 * 1024;
fn build_json_error_body(error: &str) -> String {
let escaped = serde_json::to_string(error).unwrap_or_else(|_| format!("\"{}\"", error));
format!(r#"{{"success":false,"error":{escaped}}}"#)
}
async fn write_http_response_inner(
stream: &mut tokio::net::TcpStream,
status: &str,
content_type: &str,
body: &[u8],
include_cors: bool,
) {
let cors_headers = if include_cors { CORS_HEADERS } else { "" };
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n{cors_headers}\r\n",
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.write_all(body).await;
}
async fn write_http_response(
stream: &mut tokio::net::TcpStream,
status: &str,
content_type: &str,
body: &[u8],
) {
write_http_response_inner(stream, status, content_type, body, true).await;
}
async fn write_http_response_no_cors(
stream: &mut tokio::net::TcpStream,
status: &str,
content_type: &str,
body: &[u8],
) {
write_http_response_inner(stream, status, content_type, body, false).await;
}
async fn write_json_error_response_no_cors(
stream: &mut tokio::net::TcpStream,
status: &'static str,
error: &str,
) {
let body = build_json_error_body(error);
write_http_response_no_cors(
stream,
status,
"application/json; charset=utf-8",
body.as_bytes(),
)
.await;
}
fn parse_request_method_and_path(request: &str) -> (&str, &str) {
let first_line = request.lines().next().unwrap_or("");
let method = first_line.split_whitespace().next().unwrap_or("GET");
let path = first_line.split_whitespace().nth(1).unwrap_or("/");
(method, path)
}
fn is_websocket_upgrade(request: &str) -> bool {
request.lines().any(|line| {
if let Some((name, value)) = line.split_once(':') {
name.trim().eq_ignore_ascii_case("upgrade")
&& value.trim().eq_ignore_ascii_case("websocket")
} else {
false
}
})
}
fn request_header_value<'a>(request: &'a str, name: &str) -> Option<&'a str> {
request.lines().find_map(|line| {
let (header_name, value) = line.split_once(':')?;
if header_name.trim().eq_ignore_ascii_case(name) {
Some(value.trim())
} else {
None
}
})
}
fn normalize_origin_authority(origin: &str) -> Option<String> {
let url = url::Url::parse(origin).ok()?;
let host = url.host_str()?.to_ascii_lowercase();
let host = if host.contains(':') {
format!("[{host}]")
} else {
host
};
Some(match url.port() {
Some(port) => format!("{host}:{port}"),
None => host,
})
}
fn normalize_host_authority(host: &str) -> String {
let host = host.trim().to_ascii_lowercase();
if let Some(bracket_end) = host.rfind(']') {
if bracket_end == host.len() - 1 {
return host;
}
if host.as_bytes().get(bracket_end + 1) == Some(&b':') {
let port = &host[bracket_end + 2..];
if port == "80" || port == "443" {
return host[..=bracket_end].to_string();
}
}
return host;
}
if let Some((name, port)) = host.rsplit_once(':') {
if !name.contains(':') && (port == "80" || port == "443") {
return name.to_string();
}
}
host
}
fn header_matches_host(request: &str, header_name: &str) -> Option<bool> {
let authority =
request_header_value(request, header_name).and_then(normalize_origin_authority)?;
let host = request_header_value(request, "host").map(normalize_host_authority)?;
Some(authority == host)
}
/// Validates that a proxied WebSocket request either has no Origin header or
/// presents an Origin whose authority matches the request Host header.
fn is_same_origin_ws_request(request: &str) -> bool {
match header_matches_host(request, "origin") {
Some(matches) => matches,
None => request_header_value(request, "origin").is_none(),
}
}
/// Validates that an HTTP session-proxy request came from a same-origin page.
///
/// For GET requests we require either a same-origin `Origin` or a same-origin
/// `Referer` so browsers cannot hit the proxy routes via side-channel tags or
/// arbitrary cross-origin fetches.
fn is_same_origin_http_request(request: &str) -> bool {
matches!(header_matches_host(request, "origin"), Some(true))
|| matches!(header_matches_host(request, "referer"), Some(true))
}
/// Parse a dashboard route of the form `/api/session/<port>/<endpoint>`.
fn parse_session_proxy_route(path: &str) -> Result<(u16, SessionProxyEndpoint), &'static str> {
if !path.starts_with("/api/session/") {
return Err("Invalid session proxy route.");
}
let mut parts = path.split('/');
if parts.next() != Some("") || parts.next() != Some("api") || parts.next() != Some("session") {
return Err("Invalid session proxy route.");
}
let port_str = parts.next().ok_or("Missing session proxy port.")?;
if port_str.is_empty() {
return Err("Missing session proxy port.");
}
let endpoint = match parts.next().ok_or("Missing session proxy endpoint.")? {
"tabs" => SessionProxyEndpoint::Tabs,
"status" => SessionProxyEndpoint::Status,
"stream" => SessionProxyEndpoint::Stream,
_ => return Err("Unknown session proxy endpoint."),
};
if parts.next().is_some() {
return Err("Unexpected path segments in session proxy route.");
}
let port = port_str
.parse::<u16>()
.map_err(|_| "Session proxy port must be a valid TCP port.")?;
if port == 0 {
return Err("Session proxy port must be a valid TCP port.");
}
Ok((port, endpoint))
}
fn sessions_json_has_active_port(sessions_json: &str, port: u16) -> Result<bool, String> {
let sessions: Vec<Value> = serde_json::from_str(sessions_json)
.map_err(|e| format!("Failed to parse active sessions: {e}"))?;
Ok(sessions.iter().any(|session| {
session
.get("port")
.and_then(|value| value.as_u64())
.map(|value| value == u64::from(port))
.unwrap_or(false)
}))
}
fn require_active_session_port(port: u16) -> Result<(), DashboardProxyError> {
let sessions_json = discover_sessions();
let is_active = sessions_json_has_active_port(&sessions_json, port)
.map_err(DashboardProxyError::bad_gateway)?;
if is_active {
Ok(())
} else {
Err(DashboardProxyError::not_found(format!(
"No active session is listening on port {port}."
)))
}
}
fn split_http_response(response: &[u8]) -> Result<(&[u8], &[u8]), String> {
if let Some(header_end) = response.windows(4).position(|window| window == b"\r\n\r\n") {
let body_start = header_end + 4;
return Ok((&response[..header_end], &response[body_start..]));
}
if let Some(header_end) = response.windows(2).position(|window| window == b"\n\n") {
let body_start = header_end + 2;
return Ok((&response[..header_end], &response[body_start..]));
}
Err("Upstream response was missing an HTTP header terminator.".to_string())
}
fn parse_upstream_http_response(response: &[u8]) -> Result<(String, String, Vec<u8>), String> {
let (header_bytes, body) = split_http_response(response)?;
let header_str = std::str::from_utf8(header_bytes)
.map_err(|e| format!("Upstream response headers were not valid UTF-8: {e}"))?;
let mut lines = header_str.lines();
let status_line = lines
.next()
.ok_or_else(|| "Upstream response was missing a status line.".to_string())?;
let status = status_line
.split_once(' ')
.map(|(_, status)| status.trim().to_string())
.filter(|status| !status.is_empty())
.ok_or_else(|| "Upstream response status line was malformed.".to_string())?;
let content_type = lines
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.trim().eq_ignore_ascii_case("content-type") {
Some(value.trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "application/json; charset=utf-8".to_string());
Ok((status, content_type, body.to_vec()))
}
/// Proxy dashboard-origin HTTP requests for session tabs or status to the loopback session server.
async fn proxy_session_http_route(
port: u16,
endpoint: SessionProxyEndpoint,
) -> Result<(String, String, Vec<u8>), DashboardProxyError> {
debug_assert!(matches!(
endpoint,
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status
));
require_active_session_port(port)?;
let upstream_path = match endpoint {
SessionProxyEndpoint::Tabs => "/api/tabs",
SessionProxyEndpoint::Status => "/api/status",
SessionProxyEndpoint::Stream => unreachable!("stream routes use the WebSocket proxy"),
};
let request = format!(
"GET {upstream_path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
);
tokio::time::timeout(PROXY_TIMEOUT, async {
let mut upstream = tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.map_err(|e| {
DashboardProxyError::bad_gateway(format!(
"Failed to connect to session {port}: {e}"
))
})?;
upstream.write_all(request.as_bytes()).await.map_err(|e| {
DashboardProxyError::bad_gateway(format!(
"Failed to proxy request to session {port}: {e}"
))
})?;
let mut response = Vec::new();
(&mut upstream)
.take(PROXY_MAX_RESPONSE_SIZE + 1)
.read_to_end(&mut response)
.await
.map_err(|e| {
DashboardProxyError::bad_gateway(format!(
"Failed to read session {port} response: {e}"
))
})?;
if response.len() as u64 > PROXY_MAX_RESPONSE_SIZE {
return Err(DashboardProxyError::bad_gateway(format!(
"Session {port} response exceeded {PROXY_MAX_RESPONSE_SIZE} bytes."
)));
}
parse_upstream_http_response(&response).map_err(DashboardProxyError::bad_gateway)
})
.await
.map_err(|_| {
DashboardProxyError::bad_gateway(format!(
"Session {port} proxy request timed out after {}s.",
PROXY_TIMEOUT.as_secs()
))
})?
}
/// Bridge a dashboard-origin WebSocket upgrade to the loopback session stream.
async fn proxy_session_stream(mut stream: tokio::net::TcpStream, port: u16) {
let upstream_url = format!("ws://127.0.0.1:{port}");
let (upstream_ws, _) = match tokio_tungstenite::connect_async(&upstream_url).await {
Ok(ws) => ws,
Err(error) => {
write_json_error_response_no_cors(
&mut stream,
"502 Bad Gateway",
&format!("Failed to connect to session {port}: {error}"),
)
.await;
return;
}
};
let client_ws = match tokio_tungstenite::accept_async(stream).await {
Ok(ws) => ws,
Err(_) => return,
};
let (mut client_tx, mut client_rx) = client_ws.split();
let (mut upstream_tx, mut upstream_rx) = upstream_ws.split();
loop {
tokio::select! {
message = client_rx.next() => {
match message {
Some(Ok(message)) => {
let is_close = matches!(message, Message::Close(_));
if upstream_tx.send(message).await.is_err() {
break;
}
if is_close {
break;
}
}
Some(Err(_)) | None => {
let _ = upstream_tx.send(Message::Close(None)).await;
break;
}
}
}
message = upstream_rx.next() => {
match message {
Some(Ok(message)) => {
let is_close = matches!(message, Message::Close(_));
if client_tx.send(message).await.is_err() {
break;
}
if is_close {
break;
}
}
Some(Err(_)) | None => {
let _ = client_tx.send(Message::Close(None)).await;
break;
}
}
}
}
}
}
pub async fn run_dashboard_server(port: u16) {
let addr = format!("127.0.0.1:{}", port);
let listener = match TcpListener::bind(&addr).await {
@@ -30,25 +443,82 @@ pub async fn run_dashboard_server(port: u16) {
}
async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) {
use tokio::io::AsyncReadExt;
let mut buf = vec![0u8; 8192];
let peeked_len = match stream.peek(&mut buf).await {
Ok(n) if n > 0 => n,
_ => return,
};
let peeked_request = String::from_utf8_lossy(&buf[..peeked_len]);
let (peeked_method, peeked_path) = parse_request_method_and_path(&peeked_request);
if peeked_path.starts_with("/api/session/") {
let (port, endpoint) = match parse_session_proxy_route(peeked_path) {
Ok(route) => route,
Err(error) => {
write_json_error_response_no_cors(&mut stream, "400 Bad Request", error).await;
return;
}
};
match endpoint {
SessionProxyEndpoint::Stream => {
if peeked_method != "GET" {
write_json_error_response_no_cors(
&mut stream,
"400 Bad Request",
"Session stream proxy only supports GET WebSocket upgrades.",
)
.await;
return;
}
if !is_websocket_upgrade(&peeked_request) {
write_json_error_response_no_cors(
&mut stream,
"400 Bad Request",
"Session stream proxy requires a WebSocket upgrade request.",
)
.await;
return;
}
if !is_same_origin_ws_request(&peeked_request) {
write_json_error_response_no_cors(
&mut stream,
"403 Forbidden",
"Origin does not match Host header.",
)
.await;
return;
}
if let Err(error) = require_active_session_port(port) {
write_json_error_response_no_cors(&mut stream, error.status, &error.message)
.await;
return;
}
proxy_session_stream(stream, port).await;
return;
}
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status => {
if peeked_method != "GET" {
write_json_error_response_no_cors(
&mut stream,
"400 Bad Request",
"Session proxy routes only support GET requests.",
)
.await;
return;
}
}
}
}
let n = match stream.read(&mut buf).await {
Ok(n) if n > 0 => n,
_ => return,
};
let header_str = std::str::from_utf8(&buf[..n]).unwrap_or("");
let first_line = header_str.lines().next().unwrap_or("").to_string();
let method = first_line.split_whitespace().next().unwrap_or("GET");
let path = first_line.split_whitespace().nth(1).unwrap_or("/");
let origin = header_str.lines().find_map(|line| {
if line.len() > 8 && line[..8].eq_ignore_ascii_case("origin: ") {
Some(line[8..].trim().to_string())
} else {
None
}
});
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let (method, path) = parse_request_method_and_path(&request);
let origin = request_header_value(&request, "origin").map(|value| value.to_string());
if method == "OPTIONS" {
let response = format!(
@@ -80,23 +550,67 @@ async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) {
};
let (status, resp_body) = match result {
Ok(msg) => ("200 OK", msg),
Err(e) => (
"400 Bad Request",
format!(
r#"{{"success":false,"error":{}}}"#,
serde_json::to_string(&e).unwrap_or_else(|_| format!("\"{}\"", e))
),
),
Err(e) => ("400 Bad Request", build_json_error_body(&e)),
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
resp_body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.write_all(resp_body.as_bytes()).await;
write_http_response(
&mut stream,
status,
"application/json; charset=utf-8",
resp_body.as_bytes(),
)
.await;
return;
}
if path.starts_with("/api/session/") {
let (port, endpoint) = match parse_session_proxy_route(path) {
Ok(route) => route,
Err(error) => {
write_json_error_response_no_cors(&mut stream, "400 Bad Request", error).await;
return;
}
};
match endpoint {
SessionProxyEndpoint::Tabs | SessionProxyEndpoint::Status => {
if !is_same_origin_http_request(&request) {
write_json_error_response_no_cors(
&mut stream,
"403 Forbidden",
"Origin or Referer does not match Host header.",
)
.await;
return;
}
match proxy_session_http_route(port, endpoint).await {
Ok((status, content_type, body)) => {
write_http_response_no_cors(&mut stream, &status, &content_type, &body)
.await;
}
Err(error) => {
write_json_error_response_no_cors(
&mut stream,
error.status,
&error.message,
)
.await;
}
}
return;
}
SessionProxyEndpoint::Stream => {
write_json_error_response_no_cors(
&mut stream,
"400 Bad Request",
"Session stream proxy requires a WebSocket upgrade request.",
)
.await;
return;
}
}
}
let (status, content_type, body): (&str, &str, Vec<u8>) = if path == "/api/sessions" {
(
"200 OK",
@@ -113,19 +627,10 @@ async fn handle_dashboard_connection(mut stream: tokio::net::TcpStream) {
serve_embedded_file(path)
};
let response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n{CORS_HEADERS}\r\n",
status,
content_type,
body.len()
);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.write_all(&body).await;
write_http_response(&mut stream, status, content_type, &body).await;
}
async fn read_post_body(stream: &mut tokio::net::TcpStream, initial: &[u8], n: usize) -> String {
use tokio::io::AsyncReadExt;
let header_end = initial[..n]
.windows(4)
.position(|w| w == b"\r\n\r\n")
@@ -145,12 +650,12 @@ async fn read_post_body(stream: &mut tokio::net::TcpStream, initial: &[u8], n: u
.lines()
.find_map(|l| {
if l.len() > 16 && l[..16].eq_ignore_ascii_case("content-length: ") {
l[16..].trim().parse().ok()
l[16..].trim().parse::<usize>().ok()
} else {
let lower = l.to_lowercase();
lower
.strip_prefix("content-length:")
.and_then(|v| v.trim().parse().ok())
.and_then(|v| v.trim().parse::<usize>().ok())
}
})
.unwrap_or(0);
@@ -239,11 +744,15 @@ async fn kill_session(body: &str) -> Result<String, String> {
#[cfg(unix)]
{
// SAFETY: The PID came from the daemon-managed pidfile and is only used
// to send standard termination signals to that process.
unsafe {
libc::kill(pid as i32, libc::SIGTERM);
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
// SAFETY: A signal value of 0 performs an existence check on the same pid.
if unsafe { libc::kill(pid as i32, 0) } == 0 {
// SAFETY: The process still exists after SIGTERM, so escalate to SIGKILL.
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
@@ -293,3 +802,159 @@ pub(super) async fn spawn_session(body: &str) -> Result<String, String> {
Err(format!("Session process exited with {}", status))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_same_origin_ws_request_matching() {
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: http://localhost:4848\r\nUpgrade: websocket\r\n\r\n";
assert!(is_same_origin_ws_request(req));
}
#[test]
fn test_same_origin_ws_request_proxied() {
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.agent-browser.localhost\r\nOrigin: https://dashboard.agent-browser.localhost\r\nUpgrade: websocket\r\n\r\n";
assert!(is_same_origin_ws_request(req));
}
#[test]
fn test_normalize_origin_authority_https_without_port() {
assert_eq!(
normalize_origin_authority("https://dashboard.agent-browser.localhost"),
Some("dashboard.agent-browser.localhost".to_string())
);
}
#[test]
fn test_same_origin_ws_request_default_https_port() {
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: dashboard.agent-browser.localhost:443\r\nOrigin: https://dashboard.agent-browser.localhost\r\nUpgrade: websocket\r\n\r\n";
assert!(is_same_origin_ws_request(req));
}
#[test]
fn test_same_origin_http_request_matching_origin() {
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: http://localhost:4848\r\n\r\n";
assert!(is_same_origin_http_request(req));
}
#[test]
fn test_same_origin_http_request_matching_referer() {
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: dashboard.agent-browser.localhost:443\r\nReferer: https://dashboard.agent-browser.localhost/sessions\r\n\r\n";
assert!(is_same_origin_http_request(req));
}
#[test]
fn test_same_origin_http_request_rejects_missing_origin_and_referer() {
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\n\r\n";
assert!(!is_same_origin_http_request(req));
}
#[test]
fn test_same_origin_http_request_rejects_cross_origin_referer() {
let req = "GET /api/session/9222/tabs HTTP/1.1\r\nHost: localhost:4848\r\nReferer: https://evil.com/path\r\n\r\n";
assert!(!is_same_origin_http_request(req));
}
#[test]
fn test_same_origin_ws_request_coder() {
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: workspace.coder.com\r\nOrigin: https://workspace.coder.com\r\nUpgrade: websocket\r\n\r\n";
assert!(is_same_origin_ws_request(req));
}
#[test]
fn test_cross_origin_ws_request_rejected() {
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nOrigin: https://evil.com\r\nUpgrade: websocket\r\n\r\n";
assert!(!is_same_origin_ws_request(req));
}
#[test]
fn test_no_origin_header_allowed() {
let req = "GET /api/session/9222/stream HTTP/1.1\r\nHost: localhost:4848\r\nUpgrade: websocket\r\n\r\n";
assert!(is_same_origin_ws_request(req));
}
#[test]
fn test_parse_session_proxy_route_valid() {
assert_eq!(
parse_session_proxy_route("/api/session/9222/tabs"),
Ok((9222, SessionProxyEndpoint::Tabs))
);
assert_eq!(
parse_session_proxy_route("/api/session/1337/status"),
Ok((1337, SessionProxyEndpoint::Status))
);
assert_eq!(
parse_session_proxy_route("/api/session/65535/stream"),
Ok((65535, SessionProxyEndpoint::Stream))
);
}
#[test]
fn test_parse_session_proxy_route_invalid() {
assert!(parse_session_proxy_route("/api/session/0/tabs").is_err());
assert!(parse_session_proxy_route("/api/session/not-a-port/tabs").is_err());
assert!(parse_session_proxy_route("/api/session/70000/tabs").is_err());
assert!(parse_session_proxy_route("/api/session/9222").is_err());
assert!(parse_session_proxy_route("/api/session/9222/unknown").is_err());
assert!(parse_session_proxy_route("/api/session/9222/tabs/extra").is_err());
}
#[test]
fn test_parse_session_proxy_route_path_traversal() {
assert!(parse_session_proxy_route("/api/session/9222/tabs/..").is_err());
assert!(parse_session_proxy_route("/api/session/9222/tabs/../status").is_err());
assert!(parse_session_proxy_route("/api/session/9222/../../etc/passwd").is_err());
assert!(parse_session_proxy_route("/api/session/../session/9222/tabs").is_err());
}
#[test]
fn test_parse_session_proxy_route_double_slashes() {
assert!(parse_session_proxy_route("/api/session//9222/tabs").is_err());
assert!(parse_session_proxy_route("/api//session/9222/tabs").is_err());
assert!(parse_session_proxy_route("//api/session/9222/tabs").is_err());
}
#[test]
fn test_parse_session_proxy_route_trailing_slash() {
assert!(parse_session_proxy_route("/api/session/9222/tabs/").is_err());
assert!(parse_session_proxy_route("/api/session/9222/status/").is_err());
assert!(parse_session_proxy_route("/api/session/9222/stream/").is_err());
}
#[test]
fn test_parse_session_proxy_route_encoded_paths() {
assert!(parse_session_proxy_route("/api/session/9222/tabs%20extra").is_err());
assert!(parse_session_proxy_route("/api/session/%39%32%32%32/tabs").is_err());
}
#[test]
fn test_sessions_json_has_active_port() {
let sessions_json = r#"[
{"session":"alpha","port":9222,"engine":"chrome"},
{"session":"beta","port":9333,"engine":"chrome"}
]"#;
assert_eq!(sessions_json_has_active_port(sessions_json, 9222), Ok(true));
assert_eq!(
sessions_json_has_active_port(sessions_json, 9444),
Ok(false)
);
}
#[test]
fn test_sessions_json_has_active_port_invalid_json() {
assert!(sessions_json_has_active_port("{", 9222).is_err());
}
#[test]
fn test_parse_upstream_http_response() {
let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nConnection: close\r\n\r\n{\"ok\":true}";
let parsed = parse_upstream_http_response(response).expect("response should parse");
assert_eq!(parsed.0, "200 OK");
assert_eq!(parsed.1, "application/json; charset=utf-8");
assert_eq!(parsed.2, b"{\"ok\":true}".to_vec());
}
}
+5
View File
@@ -2554,6 +2554,11 @@ Running 'agent-browser dashboard' with no subcommand is equivalent to 'dashboard
The dashboard runs as a standalone background process, independent of
browser sessions. All sessions automatically stream to the dashboard.
It works from http://localhost:4848 or a proxied/forwarded URL that
reaches the dashboard server, such as https://dashboard.agent-browser.localhost
or a Coder workspace URL. The browser stays on the dashboard origin;
session tabs, status, and stream traffic are proxied internally, so
session ports do not need to be exposed.
Options:
--port <n> Port for the dashboard server (default: 4848)