diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index 68ea1845..4c43e4a1 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -639,8 +639,8 @@ fn unix_now_seconds() -> u64 { mod tests { use super::*; use crate::auth::{ - WorkerMutationSourceExpectation, decode_worker_mutation_source_claims, - verify_worker_mutation_source_proof, + WorkerMutationSourceExpectation, decode_runtime_request_source_claims, + decode_worker_mutation_source_claims, verify_worker_mutation_source_proof, }; #[test] @@ -846,7 +846,7 @@ mod tests { } #[test] - fn ordinary_workspace_forwarding_stamps_runtime_identity_and_signed_source_proof() { + fn ordinary_workspace_forwarding_stamps_runtime_identity_and_signs_path_and_query() { use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::Mutex; @@ -875,15 +875,32 @@ mod tests { ) .with_runtime_request_source(&identity, "server-a"); let response = client - .execute(WorkspaceRequest::get("/api/w/workspace-a/tickets/search")) + .execute(WorkspaceRequest::get( + "/api/w/workspace-a/tickets/search?state=planning&limit=20", + )) .unwrap(); assert_eq!(response.status, 200); server.join().unwrap(); - let request = received.lock().unwrap().to_ascii_lowercase(); - assert!(request.contains("x-yoi-runtime-id: runtime-a")); - assert!(request.contains("x-yoi-worker-id: worker-a")); - assert!(request.contains("x-yoi-runtime-request-proof: yoi-runtime-request-v1.")); - assert!(!request.contains("authorization:")); + let request = received.lock().unwrap().clone(); + let lowercase_request = request.to_ascii_lowercase(); + assert!(lowercase_request.contains("x-yoi-runtime-id: runtime-a")); + assert!(lowercase_request.contains("x-yoi-worker-id: worker-a")); + assert!(lowercase_request.contains("x-yoi-runtime-request-proof: yoi-runtime-request-v1.")); + assert!(!lowercase_request.contains("authorization:")); + let token = request + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + .then(|| value.trim()) + }) + }) + .expect("runtime proof header"); + let claims = decode_runtime_request_source_claims(token).unwrap(); + assert_eq!( + claims.path, + "/api/w/workspace-a/tickets/search?state=planning&limit=20" + ); } #[test] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 0e5d7c1c..bcbefb77 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -903,6 +903,12 @@ async fn resolve_server_actor( resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await } +fn signed_request_target(uri: &Uri) -> &str { + uri.path_and_query() + .map(|path_and_query| path_and_query.as_str()) + .unwrap_or_else(|| uri.path()) +} + async fn authorize_scoped_workspace_request( api: &WorkspaceServerApi, workspace_id: &str, @@ -915,7 +921,7 @@ async fn authorize_scoped_workspace_request( .map(str::to_owned); if let Some(proof) = proof { let method = request.method().as_str().to_owned(); - let path = request.uri().path().to_owned(); + let path = signed_request_target(request.uri()).to_owned(); let body = std::mem::take(request.body_mut()); let body = axum::body::to_bytes(body, 16 * 1024 * 1024) .await @@ -987,7 +993,7 @@ async fn authorize_workspace_api_request( .map(str::to_owned); if let Some(proof) = proof { let method = request.method().as_str().to_owned(); - let path = request.uri().path().to_owned(); + let path = signed_request_target(request.uri()).to_owned(); let body = std::mem::take(request.body_mut()); let Ok(body) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else { return StatusCode::BAD_REQUEST.into_response(); @@ -10851,7 +10857,7 @@ async fn scoped_post_internal_runtime_resource_fetch( .get::() .cloned(); let method = request.method().as_str().to_owned(); - let path = request.uri().path().to_owned(); + let path = signed_request_target(request.uri()).to_owned(); let body = axum::body::to_bytes(request.into_body(), 16 * 1024 * 1024) .await .map_err(|error| { @@ -19859,6 +19865,68 @@ mod tests { )); } + #[tokio::test] + async fn runtime_request_proof_verifies_path_and_query_for_ticket_search() { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + seed_worker_source_member(&api, "runtime-test", "worker-test"); + let signer = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity); + let signed_target = + format!("/api/w/{TEST_WORKSPACE_ID}/tickets/search?state=active&limit=20"); + let tampered_target = + format!("/api/w/{TEST_WORKSPACE_ID}/tickets/search?state=all&limit=20"); + let issue = |target: &str| { + signer + .issue( + "server-test", + TEST_WORKSPACE_ID, + Some("worker-test"), + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + "GET", + target, + b"", + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX), + 30, + ) + .unwrap() + }; + let app = build_router(api); + + let tampered = app + .clone() + .oneshot( + Request::builder() + .uri(&tampered_target) + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + issue(&signed_target), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(tampered.status(), StatusCode::UNAUTHORIZED); + + let valid = app + .oneshot( + Request::builder() + .uri(&signed_target) + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + issue(&signed_target), + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(valid.status(), StatusCode::OK); + } + #[tokio::test] async fn internal_resource_fetch_rest_returns_typed_missing_resource() { let workspace = tempfile::tempdir().unwrap();