# CORS Configuration Guide **Last Updated**: 2026-01-03 **Related Tasks**: T009 (Tests), T010 (Implementation) **Status**: Implemented (Phase 0.5) ## Overview This document describes the Cross-Origin Resource Sharing (CORS) configuration system implemented in the STA backend. The CORS middleware enables the Vue.js frontend (deployed on Cloudflare Pages) to communicate with the Rust backend (deployed on a Raspberry Pi behind Traefik). ### Why CORS Configuration Matters The backend and frontend are deployed on different domains: - **Frontend**: `https://sta.example.com` (Cloudflare Pages) - **Backend**: Raspberry Pi behind Traefik reverse proxy Without proper CORS configuration, browsers block cross-origin requests from the frontend to the backend API. ## Architecture Context ### Production Setup ``` User Browser ↓ HTTPS Frontend (Cloudflare Pages CDN) ↓ HTTPS (cross-origin request) Traefik Reverse Proxy (Raspberry Pi) ↓ Authelia authentication middleware ↓ HTTP (local network) Backend API (Raspberry Pi) ↓ Modbus TCP Relay Device (local network) ``` ### CORS Implications 1. **Origin Validation**: Backend must explicitly allow requests from `https://sta.example.com` 2. **Credentials Support**: Traefik + Authelia authentication requires `allow_credentials: true` 3. **Preflight Caching**: Proper `max_age` reduces unnecessary OPTIONS requests 4. **Security**: Restrictive defaults prevent unauthorized access ## Configuration Structure ### CorsSettings Struct Located in `backend/src/settings.rs` (lines 217-232): ```rust #[derive(Debug, serde::Deserialize, Clone)] pub struct CorsSettings { pub allowed_origins: Vec, pub allow_credentials: bool, pub max_age_secs: i32 } impl Default for CorsSettings { fn default() -> Self { Self { allowed_origins: vec![], // Restrictive fail-safe allow_credentials: false, // No credentials by default max_age_secs: 3600 // 1 hour preflight cache } } } ``` ### Design Decisions #### Hybrid Configuration Approach The implementation uses a **hybrid approach** (Option C from research): **Configurable via YAML**: - `allowed_origins`: Deployment-specific origins (development vs. production) - `allow_credentials`: Whether to allow cookies/auth headers - `max_age_secs`: How long browsers cache preflight responses **Hardcoded in Implementation** (will be in T014): - **Methods**: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS` (API-specific) - **Headers**: `content-type`, `authorization` (minimum for API) #### Rationale - **Simplicity**: Configuration only includes deployment-specific values - **Security**: Methods and headers are determined by API requirements, not environment - **Maintainability**: Reduces configuration surface area - **Fail-Safe Defaults**: Empty `allowed_origins` prevents accidental permissive CORS ## Environment-Specific Configuration ### Development Environment **File**: `backend/settings/development.yaml` ```yaml cors: allowed_origins: - "*" # Permissive for local development allow_credentials: false max_age_secs: 3600 frontend_url: http://localhost:5173 # Vite default port ``` **Purpose**: Allows development with Vite dev server without CORS errors. **Security Trade-off**: Wildcard origin (`*`) is acceptable in development but **never in production**. ### Production Environment **File**: `backend/settings/production.yaml` (to be created in T012) ```yaml cors: allowed_origins: - "https://sta.example.com" # Specific Cloudflare Pages URL allow_credentials: true # Required for Authelia authentication max_age_secs: 3600 frontend_url: "https://sta.example.com" ``` **Purpose**: Restrictive CORS for production security. **Critical Constraint**: When `allow_credentials: true`, wildcard origins (`*`) are **not allowed** by browsers (security policy violation). ## Implementation Details ### Integration with Settings System The `CorsSettings` struct is integrated into the main `Settings` struct (line 30): ```rust #[derive(Debug, serde::Deserialize, Clone, Default)] pub struct Settings { pub application: ApplicationSettings, pub debug: bool, pub frontend_url: String, pub rate_limit: RateLimitSettings, pub modbus: ModbusSettings, pub relay: RelaySettings, #[serde(default)] // Uses Default::default() if missing pub cors: CorsSettings, } ``` The `#[serde(default)]` attribute ensures backward compatibility: if the `cors` section is missing from YAML, it uses the restrictive `Default` implementation. ### Loading and Precedence Configuration is loaded with this precedence (lowest to highest): 1. `backend/settings/base.yaml` (baseline settings) 2. `backend/settings/{environment}.yaml` (development or production) 3. Environment variables with `APP__` prefix (e.g., `APP__CORS__ALLOWED_ORIGINS`) **Example Environment Variable Override**: ```bash APP__CORS__ALLOWED_ORIGINS='["https://example.com"]' cargo run ``` ## Test Coverage ### Tests Written in T009 Located in `backend/src/settings.rs` (lines 361-471), the following tests were written **before** implementation (TDD): #### 1. Basic Deserialization (`cors_settings_deserialize_from_yaml`) ```rust #[test] fn cors_settings_deserialize_from_yaml() { let yaml = r#" allowed_origins: - "http://localhost:5173" - "https://sta.example.com" allow_credentials: true max_age_secs: 7200 "#; let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); assert_eq!(settings.allowed_origins.len(), 2); assert_eq!(settings.allowed_origins[0], "http://localhost:5173"); assert_eq!(settings.allowed_origins[1], "https://sta.example.com"); assert!(settings.allow_credentials); assert_eq!(settings.max_age_secs, 7200); } ``` **Purpose**: Verifies that YAML configuration correctly deserializes into `CorsSettings`. #### 2. Restrictive Fail-Safe Defaults (`cors_settings_default_has_empty_origins`) ```rust #[test] fn cors_settings_default_has_empty_origins() { let settings = CorsSettings::default(); assert!( settings.allowed_origins.is_empty(), "Default CorsSettings should have empty allowed_origins for restrictive fail-safe" ); assert!( !settings.allow_credentials, "Default CorsSettings should have credentials disabled" ); assert_eq!( settings.max_age_secs, 3600, "Default CorsSettings should have 1 hour max_age" ); } ``` **Purpose**: Ensures defaults are **restrictive** (empty origins, no credentials), preventing accidental permissive CORS. #### 3. Wildcard Origin Support (`cors_settings_with_wildcard_deserializes`) ```rust #[test] fn cors_settings_with_wildcard_deserializes() { let yaml = r#" allowed_origins: - "*" allow_credentials: false max_age_secs: 3600 "#; let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); assert_eq!(settings.allowed_origins.len(), 1); assert_eq!(settings.allowed_origins[0], "*"); assert!(!settings.allow_credentials); assert_eq!(settings.max_age_secs, 3600); } ``` **Purpose**: Verifies wildcard origin support for development environments. #### 4. Integration with Settings (`settings_loads_cors_section_from_yaml`) ```rust #[test] fn settings_loads_cors_section_from_yaml() { let yaml_content = r#" application: name: "test-app" version: "1.0.0" port: 3100 host: "127.0.0.1" base_url: "http://127.0.0.1:3100" protocol: "http" debug: false frontend_url: "http://localhost:5173" rate_limit: enabled: true burst_size: 100 per_seconds: 60 cors: allowed_origins: - "http://localhost:5173" allow_credentials: false max_age_secs: 3600 modbus: host: "192.168.0.200" port: 502 slave_id: 0 timeout_secs: 5 relay: label_max_length: 50 "#; let settings: Settings = serde_yaml::from_str(yaml_content).unwrap(); assert_eq!(settings.cors.allowed_origins.len(), 1); assert_eq!(settings.cors.allowed_origins[0], "http://localhost:5173"); assert!(!settings.cors.allow_credentials); assert_eq!(settings.cors.max_age_secs, 3600); } ``` **Purpose**: Verifies that `CorsSettings` loads correctly as part of the full `Settings` struct. #### 5. Partial Deserialization with Defaults (`cors_settings_deserialize_with_defaults`) ```rust #[test] fn cors_settings_deserialize_with_defaults() { let yaml = r#" allowed_origins: - "https://example.com" "#; let settings: CorsSettings = serde_yaml::from_str(yaml).unwrap(); assert_eq!(settings.allowed_origins.len(), 1); assert_eq!(settings.allowed_origins[0], "https://example.com"); // These should use defaults assert!(!settings.allow_credentials); assert_eq!(settings.max_age_secs, 3600); } ``` **Purpose**: Ensures partial YAML (missing fields) uses `Default` values correctly via `#[serde(default)]` on individual fields. ### Running Tests ```bash # Run all settings tests cargo test -p sta settings # Run CORS-specific tests cargo test -p sta cors # Run with output cargo test -p sta cors -- --nocapture ``` ## Security Considerations ### Critical Security Rules #### 1. Wildcard + Credentials Constraint **Browser Security Policy**: When `allow_credentials: true`, wildcard origins (`*`) are **forbidden** by the CORS specification. **Enforcement**: The upcoming `build_cors()` function (T014) will panic during startup if this constraint is violated: ```rust if settings.allow_credentials && settings.allowed_origins.contains(&"*".to_string()) { panic!("CORS misconfiguration: wildcard origin not allowed with credentials=true"); } ``` **Rationale**: Prevents credential leakage to arbitrary origins. #### 2. Fail-Safe Defaults **Design**: `Default::default()` provides **restrictive** settings: - Empty `allowed_origins` (blocks all origins) - `allow_credentials: false` - `max_age_secs: 3600` (1 hour) **Rationale**: If configuration is missing or incomplete, the system defaults to **denying** access rather than accidentally allowing all origins. #### 3. Production-Specific Origins **Requirement**: Production YAML must specify exact origins: ```yaml # ✅ CORRECT allowed_origins: - "https://sta.example.com" # ❌ WRONG in production allowed_origins: - "*" ``` **Rationale**: Prevents unauthorized websites from making API requests. ### Attack Vectors Mitigated | Attack | Mitigation | |--------|------------| | **CSRF via Foreign Domains** | Specific `allowed_origins` prevent malicious sites from making requests | | **Credential Theft** | `allow_credentials` only sent to whitelisted origins | | **Data Exfiltration** | Restrictive CORS prevents unauthorized cross-origin reads | | **Replay Attacks** | Authelia middleware (outside CORS) handles this | ## Usage Examples ### Example 1: Adding Multiple Origins For staging + production environments: ```yaml cors: allowed_origins: - "https://sta.example.com" # Production - "https://staging.sta.example.com" # Staging allow_credentials: true max_age_secs: 3600 ``` ### Example 2: Development Without CORS Restrictions ```yaml cors: allowed_origins: - "*" allow_credentials: false max_age_secs: 3600 ``` **Warning**: Only use in development environments. ### Example 3: Environment Variable Override For testing with a different origin without modifying YAML: ```bash APP__CORS__ALLOWED_ORIGINS='["https://test.example.com"]' \ APP__CORS__ALLOW_CREDENTIALS=true \ cargo run ``` **Note**: The `allowed_origins` value must be valid JSON array syntax. ## Troubleshooting ### CORS Error: "No 'Access-Control-Allow-Origin' header is present" **Cause**: Frontend origin not in `allowed_origins` list. **Solution**: 1. Check `allowed_origins` in YAML configuration 2. Verify frontend URL exactly matches (including protocol and port) 3. Restart backend after configuration changes ### CORS Error: "Credentials flag is 'true' but the origin is '*'" **Cause**: Invalid configuration with `allow_credentials: true` and wildcard origin. **Solution**: Replace `"*"` with specific origins: ```yaml cors: allowed_origins: - "https://sta.example.com" allow_credentials: true ``` ### Preflight Requests Failing (OPTIONS) **Cause**: Backend not allowing OPTIONS method (will be fixed in T014). **Temporary Workaround**: None - wait for T014 implementation. **Permanent Solution**: The upcoming `build_cors()` function will hardcode: ```rust cors.allow_methods(vec![ Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS ]); ``` ### Configuration Not Loading **Symptoms**: Default (empty) `allowed_origins` is used instead of YAML values. **Debugging Steps**: 1. Check file exists: `backend/settings/development.yaml` or `production.yaml` 2. Verify YAML syntax (use `yamllint`) 3. Check environment: `echo $APP_ENVIRONMENT` (should be `dev`, `development`, `prod`, or `production`) 4. Enable debug logging to see loaded configuration: ```bash RUST_LOG=sta=debug cargo run ``` ### Headers Not Allowed **Cause**: Custom headers not in allowed list (will be in T014). **Current Allowed Headers** (to be implemented): - `content-type` (for JSON request bodies) - `authorization` (for Authelia authentication tokens) **Adding Custom Headers**: Requires modifying `build_cors()` function (T014). ## Dependencies ### Rust Crates - **serde** (1.0.228): Deserialization from YAML - **serde_yaml** (0.9.34): YAML parsing Added in T010 via `backend/Cargo.toml`: ```toml [dependencies] serde = "1.0.228" serde_yaml = "0.9.34" ``` ### Related Configuration Files | File | Purpose | |------|---------| | `backend/src/settings.rs` | `CorsSettings` struct definition | | `backend/settings/base.yaml` | Baseline configuration (no CORS section yet) | | `backend/settings/development.yaml` | Development CORS (permissive) | | `backend/settings/production.yaml` | Production CORS (restrictive) - to be created in T012 | ## Next Steps (Remaining Tasks) ### T011: Update development.yaml - Add `cors:` section with permissive settings - Update `frontend_url` to `http://localhost:5173` (Vite default) ### T012: Create production.yaml - Add `cors:` section with restrictive settings - Use `https://sta.example.com` as allowed origin - Set `allow_credentials: true` for Authelia ### T013-T014: Implement build_cors() Function - Create `build_cors(settings: &CorsSettings) -> Cors` in `startup.rs` - Validate wildcard + credentials constraint - Hardcode methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) - Hardcode headers (content-type, authorization) - Add structured logging ### T015: Replace Cors::new() in Middleware Chain - Update `startup.rs` line ~86 - Call `build_cors(&value.settings.cors)` ### T016: Integration Tests - Write tests verifying CORS headers in HTTP responses - Test OPTIONS preflight requests - Verify `Access-Control-Allow-Origin` header ## References ### Internal Documentation - [CORS Research Document](../specs/001-modbus-relay-control/research-cors.md) - Complete research and decision log - [Feature Specification](../specs/001-modbus-relay-control/spec.md) - FR-022a requirement - [Implementation Tasks](../specs/001-modbus-relay-control/tasks.md) - T009-T016 task breakdown ### External Resources - [MDN CORS Guide](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) - [Poem CORS Middleware](https://docs.rs/poem/latest/poem/middleware/struct.Cors.html) - [CORS Specification (W3C)](https://www.w3.org/TR/cors/) ## Changelog | Date | Task | Change | |------|------|--------| | 2026-01-02 | Research | CORS configuration research completed | | 2026-01-03 | T009 | Test suite written (5 tests, TDD approach) | | 2026-01-03 | T010 | `CorsSettings` struct implemented with defaults | | 2026-01-03 | Documentation | This guide created | --- **Maintainer Notes**: This configuration follows the project's **Type-Driven Development (TyDD)** and **Test-Driven Development (TDD)** principles. Tests were written first (T009), then the implementation (T010) was created to pass those tests. The upcoming `build_cors()` function (T014) will complete the CORS feature by applying these settings to the Poem middleware chain.