diff --git a/backend/Cargo.lock b/backend/Cargo.lock index aad5fee..165b395 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -261,6 +261,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", + "windows-sys", +] + +[[package]] "windows-sys 0.61.2", ] @@ -359,6 +363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-task", "futures-io", "futures-sink", "futures-task", @@ -742,6 +747,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", + "windows-sys", "windows-sys 0.61.2", ] @@ -793,6 +799,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", + "redox_syscall", "redox_syscall 0.5.18", "smallvec", "windows-link", @@ -865,6 +872,12 @@ name = "predifi-backend" version = "0.1.0" dependencies = [ "axum", + "http", + "http-body-util", + "serde_json", + "tokio", + "tower 0.4.13", + "tower-http", "dotenvy", "http", "http-body-util", @@ -1372,6 +1385,132 @@ dependencies = [ ] [[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1406,6 +1545,33 @@ dependencies = [ ] [[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" name = "tokio-stream" version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1483,6 +1649,81 @@ dependencies = [ ] [[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] name = "tracing-core" version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 65c681d..ca76317 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -15,6 +15,7 @@ tokio = { version = "1", features = ["full"] } tower = "0.4" http = "1" serde_json = "1" +tower-http = { version = "0.6.8", features = ["cors"] } dotenvy = "0.15" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/backend/src/main.rs b/backend/src/main.rs index d12305c..2475a2c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,5 +1,6 @@ //! # predifi-backend //! +//! A minimal Axum HTTP server with CORS and request-logging middleware. //! A minimal Axum HTTP server that demonstrates the request-logging middleware //! and a versioned API router layout. //! @@ -22,10 +23,64 @@ pub mod request_logger; pub mod routes; use axum::{routing::get, Json, Router}; +use http::HeaderValue; use config::Config; use request_logger::LoggingLayer; use routes::v1; use serde_json::json; +use tower_http::cors::{AllowOrigin, CorsLayer}; + +/// Allowed frontend origins for CORS. +/// +/// Add any additional frontend URLs here. +/// In production, replace with your actual deployed frontend URL. +const ALLOWED_ORIGINS: &[&str] = &[ + "http://localhost:3000", + "http://localhost:5173", + "https://predifi.app", +]; + +/// Build the CORS middleware layer. +/// +/// Only requests from the origins listed in `ALLOWED_ORIGINS` are permitted. +/// All other origins will be rejected by the browser. +pub fn build_cors() -> CorsLayer { + let origins: Vec = ALLOWED_ORIGINS + .iter() + .filter_map(|origin| origin.parse().ok()) + .collect(); + + CorsLayer::new() + .allow_origin(AllowOrigin::list(origins)) + .allow_methods([ + http::Method::GET, + http::Method::POST, + http::Method::PUT, + http::Method::DELETE, + http::Method::OPTIONS, + ]) + .allow_headers([ + http::header::CONTENT_TYPE, + http::header::AUTHORIZATION, + http::header::ACCEPT, + ]) +} + +/// Health-check handler. +/// +/// Returns HTTP 200 with basic system info: +/// - `status`: always `"ok"` when the server is running +/// - `service`: name of the service +/// - `version`: current package version from Cargo.toml +async fn health() -> Json { + Json(json!({ + "status": "ok", + "service": "predifi-backend", + "version": env!("CARGO_PKG_VERSION") + })) +} + +/// Root handler — returns a welcome message. use tracing::{error, info}; use tracing_subscriber::EnvFilter; @@ -52,13 +107,15 @@ async fn root() -> Json { })) } -/// Build the Axum router with the logging middleware attached. +/// Build the Axum router with CORS and logging middleware attached. /// /// Keeping router construction in its own function makes it easy to reuse /// in tests without binding to a real TCP port. pub fn build_router() -> Router { Router::new() .route("/", get(root)) + .route("/health", get(health)) + .layer(build_cors()) .route("/health", get(v1::health)) .nest("/api", routes::router()) .layer(LoggingLayer) diff --git a/backend/src/tests.rs b/backend/src/tests.rs index efd3bd1..dad5d59 100644 --- a/backend/src/tests.rs +++ b/backend/src/tests.rs @@ -280,4 +280,56 @@ async fn middleware_handles_multiple_requests_sequentially() { "body should contain the service name, got: {body}" ); } + + /// CORS headers must be present when a request comes from an allowed origin. + #[tokio::test] + async fn cors_allows_allowed_origin() { + use axum::http::{header, Method}; + + let response = build_router() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/health") + .header(header::ORIGIN, "http://localhost:5173") + .body(axum::body::Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + + let allow_origin = response + .headers() + .get("access-control-allow-origin") + .and_then(|v| v.to_str().ok()); + + assert_eq!( + allow_origin, + Some("http://localhost:5173"), + "CORS header should reflect the allowed origin" + ); + } + + /// Preflight OPTIONS request must return 200 for allowed origins. + #[tokio::test] + async fn cors_handles_preflight_request() { + use axum::http::{header, Method}; + + let response = build_router() + .oneshot( + Request::builder() + .method(Method::OPTIONS) + .uri("/health") + .header(header::ORIGIN, "http://localhost:5173") + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") + .body(axum::body::Body::empty()) + .expect("failed to build request"), + ) + .await + .expect("request failed"); + + assert_eq!(response.status(), StatusCode::OK); + } } diff --git a/docs/cors-middleware.md b/docs/cors-middleware.md new file mode 100644 index 0000000..63f6618 --- /dev/null +++ b/docs/cors-middleware.md @@ -0,0 +1,115 @@ +# CORS Middleware + +## Overview + +This document describes the CORS (Cross-Origin Resource Sharing) +middleware configured in `predifi-backend`. It controls which frontend +origins are allowed to make requests to the API, preventing unauthorized +cross-origin access. + +--- + +## What is CORS? + +When a browser makes a request to a different domain than the page it +is on, it performs a CORS check. The server must explicitly allow the +origin in its response headers, otherwise the browser blocks the request. + +--- + +## Allowed Origins + +The following frontend origins are permitted by default: + +- `http://localhost:3000` — local development +- `http://localhost:5173` — Vite dev server +- `https://predifi.app` — production frontend + +To add more origins, update the `ALLOWED_ORIGINS` array in `src/main.rs`. + +--- + +## Allowed Methods + +| Method | Purpose | +|--------|---------| +| GET | Read data | +| POST | Create resources | +| PUT | Update resources | +| DELETE | Remove resources | +| OPTIONS | Preflight requests | + +## Allowed Headers + +- `Content-Type` +- `Authorization` +- `Accept` + +--- + +## Implementation + +**File:** `src/main.rs` + +### `build_cors() -> CorsLayer` +Builds a `tower-http` CorsLayer configured with the allowed origins, +methods and headers. Called once during router setup. + +### `build_router() -> Router` +Attaches the CORS layer to the router before the logging layer so +CORS headers are always present in responses. + +--- + +## Security Assumptions + +- Only origins explicitly listed in `ALLOWED_ORIGINS` are permitted +- All other origins are rejected by the browser automatically +- The `Authorization` header is allowed so JWT tokens can be sent +- OPTIONS preflight requests are handled automatically by the layer +- CORS is enforced by the browser — server-to-server requests bypass it + +--- + +## Abuse and Failure Paths + +| Scenario | Behaviour | +|----------|-----------| +| Request from unlisted origin | Browser blocks response | +| Preflight OPTIONS request | Returns 200 with CORS headers | +| Request with disallowed method | Browser blocks request | +| Request with disallowed header | Browser blocks request | + +--- + +## Test Coverage + +**File:** `src/tests.rs` + +| Test | What it verifies | +|------|-----------------| +| `cors_allows_allowed_origin` | CORS header returned for allowed origin | +| `cors_handles_preflight_request` | OPTIONS preflight returns 200 | + +--- + +## Example +```bash +# Request from allowed origin +curl -H "Origin: http://localhost:5173" http://localhost:3000/health +# Response includes: access-control-allow-origin: http://localhost:5173 + +# Preflight request +curl -X OPTIONS \ + -H "Origin: http://localhost:5173" \ + -H "Access-Control-Request-Method: GET" \ + http://localhost:3000/health +``` + +--- + +## Related Files + +- `src/main.rs` — CORS configuration and router setup +- `src/tests.rs` — CORS test suite +- `Cargo.toml` — tower-http dependency with cors feature