Cors and token (#5850)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -3,7 +3,7 @@ use axum::response::Redirect;
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
Request, State,
|
Query, Request, State,
|
||||||
},
|
},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
middleware::{self, Next},
|
middleware::{self, Next},
|
||||||
@@ -21,7 +21,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::{net::SocketAddr, sync::Arc};
|
use std::{net::SocketAddr, sync::Arc};
|
||||||
use tokio::sync::{Mutex, RwLock};
|
use tokio::sync::{Mutex, RwLock};
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{AllowOrigin, Any, CorsLayer};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
use webbrowser;
|
use webbrowser;
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ struct AppState {
|
|||||||
agent: Arc<Agent>,
|
agent: Arc<Agent>,
|
||||||
cancellations: CancellationStore,
|
cancellations: CancellationStore,
|
||||||
auth_token: Option<String>,
|
auth_token: Option<String>,
|
||||||
|
ws_token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
@@ -87,17 +88,14 @@ async fn auth_middleware(
|
|||||||
req: Request,
|
req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
// Skip auth for health check
|
|
||||||
if req.uri().path() == "/api/health" {
|
if req.uri().path() == "/api/health" {
|
||||||
return Ok(next.run(req).await);
|
return Ok(next.run(req).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no auth token is configured, skip authentication entirely
|
|
||||||
let Some(ref expected_token) = state.auth_token else {
|
let Some(ref expected_token) = state.auth_token else {
|
||||||
return Ok(next.run(req).await);
|
return Ok(next.run(req).await);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for Bearer token first
|
|
||||||
if let Some(auth_header) = req.headers().get("authorization") {
|
if let Some(auth_header) = req.headers().get("authorization") {
|
||||||
if let Ok(auth_str) = auth_header.to_str() {
|
if let Ok(auth_str) = auth_header.to_str() {
|
||||||
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
if let Some(token) = auth_str.strip_prefix("Bearer ") {
|
||||||
@@ -106,7 +104,6 @@ async fn auth_middleware(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Basic auth (password-only, ignore username)
|
|
||||||
if let Some(basic_token) = auth_str.strip_prefix("Basic ") {
|
if let Some(basic_token) = auth_str.strip_prefix("Basic ") {
|
||||||
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(basic_token) {
|
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(basic_token) {
|
||||||
if let Ok(credentials) = String::from_utf8(decoded) {
|
if let Ok(credentials) = String::from_utf8(decoded) {
|
||||||
@@ -119,7 +116,6 @@ async fn auth_middleware(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentication failed - return 401 with WWW-Authenticate header
|
|
||||||
let mut response = Response::new("Authentication required".into());
|
let mut response = Response::new("Authentication required".into());
|
||||||
*response.status_mut() = StatusCode::UNAUTHORIZED;
|
*response.status_mut() = StatusCode::UNAUTHORIZED;
|
||||||
response.headers_mut().insert(
|
response.headers_mut().insert(
|
||||||
@@ -135,7 +131,6 @@ pub async fn handle_web(
|
|||||||
open: bool,
|
open: bool,
|
||||||
auth_token: Option<String>,
|
auth_token: Option<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// Setup logging
|
|
||||||
crate::logging::setup_logging(Some("goose-web"), None)?;
|
crate::logging::setup_logging(Some("goose-web"), None)?;
|
||||||
|
|
||||||
let config = goose::config::Config::global();
|
let config = goose::config::Config::global();
|
||||||
@@ -176,10 +171,34 @@ pub async fn handle_web(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let ws_token = if auth_token.is_none() {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
agent: Arc::new(agent),
|
agent: Arc::new(agent),
|
||||||
cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())),
|
||||||
auth_token,
|
auth_token: auth_token.clone(),
|
||||||
|
ws_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cors_layer = if auth_token.is_none() {
|
||||||
|
let allowed_origins = [
|
||||||
|
"http://localhost:3000".parse().unwrap(),
|
||||||
|
"http://127.0.0.1:3000".parse().unwrap(),
|
||||||
|
format!("http://{}:{}", host, port).parse().unwrap(),
|
||||||
|
];
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(AllowOrigin::list(allowed_origins))
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any)
|
||||||
|
} else {
|
||||||
|
CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any)
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -194,12 +213,7 @@ pub async fn handle_web(
|
|||||||
state.clone(),
|
state.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
))
|
))
|
||||||
.layer(
|
.layer(cors_layer)
|
||||||
CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods(Any)
|
|
||||||
.allow_headers(Any),
|
|
||||||
)
|
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
let addr: SocketAddr = format!("{}:{}", host, port).parse()?;
|
||||||
@@ -214,7 +228,6 @@ pub async fn handle_web(
|
|||||||
println!(" Press Ctrl+C to stop\n");
|
println!(" Press Ctrl+C to stop\n");
|
||||||
|
|
||||||
if open {
|
if open {
|
||||||
// Open browser
|
|
||||||
let url = format!("http://{}", addr);
|
let url = format!("http://{}", addr);
|
||||||
if let Err(e) = webbrowser::open(&url) {
|
if let Err(e) = webbrowser::open(&url) {
|
||||||
eprintln!("Failed to open browser: {}", e);
|
eprintln!("Failed to open browser: {}", e);
|
||||||
@@ -241,14 +254,15 @@ async fn serve_index() -> Result<Redirect, (http::StatusCode, String)> {
|
|||||||
|
|
||||||
async fn serve_session(
|
async fn serve_session(
|
||||||
axum::extract::Path(session_name): axum::extract::Path<String>,
|
axum::extract::Path(session_name): axum::extract::Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
) -> Html<String> {
|
) -> Html<String> {
|
||||||
let html = include_str!("../../static/index.html");
|
let html = include_str!("../../static/index.html");
|
||||||
// Inject the session name into the HTML so JavaScript can use it
|
|
||||||
let html_with_session = html.replace(
|
let html_with_session = html.replace(
|
||||||
"<script src=\"/static/script.js\"></script>",
|
"<script src=\"/static/script.js\"></script>",
|
||||||
&format!(
|
&format!(
|
||||||
"<script>window.GOOSE_SESSION_NAME = '{}';</script>\n <script src=\"/static/script.js\"></script>",
|
"<script>window.GOOSE_SESSION_NAME = '{}'; window.GOOSE_WS_TOKEN = '{}';</script>\n <script src=\"/static/script.js\"></script>",
|
||||||
session_name
|
session_name,
|
||||||
|
state.ws_token
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
Html(html_with_session)
|
Html(html_with_session)
|
||||||
@@ -324,11 +338,25 @@ async fn get_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WsQuery {
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
async fn websocket_handler(
|
async fn websocket_handler(
|
||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> impl IntoResponse {
|
Query(query): Query<WsQuery>,
|
||||||
ws.on_upgrade(|socket| handle_socket(socket, state))
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
if state.auth_token.is_none() {
|
||||||
|
let provided_token = query.token.as_deref().unwrap_or("");
|
||||||
|
if provided_token != state.ws_token {
|
||||||
|
tracing::warn!("WebSocket connection rejected: invalid token");
|
||||||
|
return Err(StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ws.on_upgrade(|socket| handle_socket(socket, state)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_socket(socket: WebSocket, state: AppState) {
|
async fn handle_socket(socket: WebSocket, state: AppState) {
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ function removeThinkingIndicator() {
|
|||||||
// Connect to WebSocket
|
// Connect to WebSocket
|
||||||
function connectWebSocket() {
|
function connectWebSocket() {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
const token = window.GOOSE_WS_TOKEN || '';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
socket = new WebSocket(wsUrl);
|
socket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
@@ -520,4 +521,4 @@ function updateSessionTitle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update title on load
|
// Update title on load
|
||||||
updateSessionTitle();
|
updateSessionTitle();
|
||||||
|
|||||||
Reference in New Issue
Block a user