# CORS Configuration Research **Date**: 2026-01-02 **Feature**: Configurable CORS for Modbus Relay Control System **Research Focus**: Production-ready CORS configuration to replace permissive defaults ## Executive Summary Current CORS implementation uses `Cors::new()` with default settings that allow ALL origins, ALL methods, and ALL headers - acceptable for development but insecure for production. This research documents how to implement configurable CORS following the project's existing patterns for middleware configuration. ## Current State Analysis ### Implementation Location - **File**: `backend/src/startup.rs:86` - **Current Code**: `.with(Cors::new())` - **Status**: Hardcoded permissive CORS with no configuration ### Security Profile (Current) - ✗ Allows ALL origins (including potentially malicious sites) - ✗ Allows ALL HTTP methods (GET, POST, DELETE, PATCH, etc.) - ✗ Allows ALL request headers - ✓ Does NOT allow credentials (default: false) - ✓ 24-hour preflight cache (default max_age: 86400) ### Configuration Gap - `settings.frontend_url` exists in `backend/src/settings.rs:20` but is NOT used for CORS - No CORS-specific settings struct - No environment-aware CORS configuration ## Research Findings ### 1. Poem CORS API **Core Configuration Methods**: ```rust Cors::new() .allow_origin(origin: &str) // Single origin .allow_origins(origins: Vec<&str>) // Multiple origins .allow_origin_regex(pattern: &str) // Wildcard patterns .allow_method(method: Method) // HTTP method .allow_methods(methods: Vec) // Multiple methods .allow_header(header: HeaderName) // Request header .allow_headers(headers: Vec) // Multiple headers .expose_header(header: HeaderName) // Response header .expose_headers(headers: Vec) // Multiple response headers .allow_credentials(bool) // Cookie/auth support .max_age(seconds: i32) // Preflight cache duration ``` **Default Behavior**: - Empty collections → permits ALL values (permissive) - `max_age`: 86400 seconds (24 hours) - `allow_credentials`: false ### 2. Production Security Best Practices #### Origin Configuration | Approach | Security | Use Case | |----------|----------|----------| | Specific origin | High | Production (single frontend domain) | | Multiple specific origins | High | Multi-environment (dev + prod) | | Wildcard patterns | Medium | Subdomains (*.example.com) | | Custom validation function | Medium | Complex rules | | Default (empty) | Low | Development only | **Recommendation**: Use specific origins from `frontend_url` setting. #### Credentials - **false**: Public APIs without authentication - **true**: APIs requiring cookies/auth headers (Authelia requires this) **For STA**: Set to `true` because Traefik uses Authelia authentication. **Critical**: When credentials=true, wildcard `*` origin is NOT allowed by browsers. Must use specific origins. #### Methods - **Minimal**: GET only (read-only endpoints) - **Standard**: GET, POST (basic REST) - **Full**: GET, POST, PUT, DELETE, PATCH (complete CRUD) **For STA**: GET, POST, PUT (relay control requires POST/PUT for state changes) #### Headers - **Essential**: Content-Type (for POST/PUT bodies) - **Authentication**: Authorization (for bearer tokens) - **Custom**: X-Request-Id, X-Custom-Header (application-specific) **For STA**: Minimum required: `content-type`, `authorization` #### Max Age | Duration | Implications | Security | |----------|--------------|----------| | 0 | No caching, preflight every request | High but inefficient | | 3600 (1 hour) | Balance security & performance | High (recommended) | | 86400 (24 hours) | Fewer preflights, slower updates | Medium | | 604800 (7 days) | Very few preflights | Low | **Recommendation**: 3600 seconds (1 hour) for production. ### 3. Deployment Architecture Context From `specs/001-modbus-relay-control/decisions.md`: **Production Setup**: - **Frontend**: Cloudflare Pages (static hosting with global CDN) - **Backend**: Raspberry Pi 3B+ (local network, same as Modbus device) - **Reverse Proxy**: Traefik on Raspberry Pi - HTTPS termination (TLS certificates) - Authelia middleware (user authentication) - Routes HTTPS requests to backend HTTP service **Communication Flow**: ``` Frontend (Cloudflare Pages) ↓ HTTPS Traefik (Raspberry Pi) ↓ Authelia authentication ↓ HTTP (local network) Backend (Raspberry Pi) ↓ Modbus TCP Relay Device (local network) ``` **CORS Implications**: - Origin must be the Cloudflare Pages URL (HTTPS) - Credentials must be allowed (Authelia auth tokens) - Backend sees requests from Traefik, but CORS origin is frontend domain ### 4. Configuration Pattern (from Rate Limiting) **Reference**: `backend/src/middleware/rate_limit.rs` **Pattern Structure**: 1. **Configuration struct** with settings 2. **Middleware implementation** using configuration 3. **Conditional instantiation** in `startup.rs` based on settings 4. **YAML configuration** in `settings/*.yaml` 5. **Environment variable overrides** via `APP__` prefix **Example from Rate Limiting**: ```rust // Settings struct pub struct RateLimitSettings { pub enabled: bool, pub burst_size: u32, pub per_seconds: u64, } // Conditional instantiation in startup.rs let rate_limit_config = if value.settings.rate_limit.enabled { RateLimitConfig::new(burst_size, per_seconds) } else { RateLimitConfig::new(u32::MAX, 1) // Effectively disabled }; let app = value.app .with(RateLimit::new(&rate_limit_config)) .with(Cors::new()) // ← Should follow same pattern .data(value.settings); ``` ## Recommended Approach ### Option A: Minimal Configuration (MVP) Add simple CORS settings that cover 80% of use cases: ```rust #[derive(Debug, serde::Deserialize, Clone)] pub struct CorsSettings { pub allowed_origins: Vec, pub allow_credentials: bool, } impl Default for CorsSettings { fn default() -> Self { Self { allowed_origins: vec!["*".to_string()], allow_credentials: false, } } } ``` **YAML Configuration**: ```yaml # development.yaml cors: allowed_origins: - "http://localhost:3000" - "http://127.0.0.1:3000" allow_credentials: false # production.yaml cors: allowed_origins: - "https://REACTED" # Cloudflare Pages URL allow_credentials: true ``` **Pros**: - Simple to implement - Covers most critical security concerns - Easy to understand and maintain **Cons**: - Limited flexibility (no method/header customization) - Cannot disable CORS entirely - No max_age configuration ### Option B: Full Configuration (Production-Ready) Complete CORS configuration matching Poem's capabilities: ```rust #[derive(Debug, serde::Deserialize, Clone)] pub struct CorsSettings { pub enabled: bool, pub allowed_origins: Vec, pub allowed_methods: Vec, pub allowed_headers: Vec, pub expose_headers: Vec, pub allow_credentials: bool, pub max_age_secs: i32, } impl Default for CorsSettings { fn default() -> Self { Self { enabled: true, allowed_origins: vec!["*".to_string()], allowed_methods: vec!["GET".to_string(), "POST".to_string(), "PUT".to_string()], allowed_headers: vec!["content-type".to_string(), "authorization".to_string()], expose_headers: vec![], allow_credentials: false, max_age_secs: 3600, } } } ``` **YAML Configuration**: ```yaml # production.yaml cors: enabled: true allowed_origins: - "https://REDACTED" allowed_methods: - "GET" - "POST" - "PUT" allowed_headers: - "content-type" - "authorization" expose_headers: - "x-ratelimit-remaining" allow_credentials: true max_age_secs: 3600 ``` **Pros**: - Complete control over CORS behavior - Can disable CORS entirely (enabled: false) - Production-ready security - Follows Poem API closely **Cons**: - More complex configuration - More YAML to maintain - Risk of misconfiguration ### Option C: Hybrid Approach (Recommended) Combine simplicity with essential production features: ```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!["*".to_string()], allow_credentials: false, max_age_secs: 3600, } } } ``` **Methods and Headers**: Hardcoded in code based on API requirements (not configurable). **Rationale**: - Origins and credentials are deployment-specific (need configuration) - Methods and headers are API-specific (shouldn't change per environment) - Simpler than Option B, more secure than Option A - Reduces configuration surface area ## Implementation Checklist ### Files to Modify 1. **`backend/src/settings.rs`** - Add `CorsSettings` struct (after `RateLimitSettings`) - Add `cors: CorsSettings` field to `Settings` struct - Add `#[serde(default)]` attribute for backward compatibility 2. **`backend/src/startup.rs`** - Import CORS-related types from Poem - Create helper method `build_cors(settings: &CorsSettings) -> Cors` - Replace `.with(Cors::new())` with `.with(Self::build_cors(&value.settings.cors))` 3. **`backend/settings/development.yaml`** - Add `cors:` section with permissive development settings - Allow localhost:3000 and 127.0.0.1:3000 4. **`backend/settings/production.yaml`** - Add `cors:` section with restrictive production settings - Use actual Cloudflare Pages URL - Set `allow_credentials: true` 5. **`specs/001-modbus-relay-control/plan.md`** - Update technical context with CORS configuration - Add CORS configuration task to implementation plan 6. **`CLAUDE.md`** (optional) - Document CORS configuration in project instructions ### Testing Strategy 1. **Unit Tests**: Verify `build_cors()` creates correct Poem `Cors` instance 2. **Integration Tests**: Verify CORS headers in HTTP responses 3. **Manual Tests**: Test with actual frontend (Vite dev server + Cloudflare Pages) ## Key Files Referenced | File | Lines | Purpose | |------|-------|---------| | `/backend/src/startup.rs` | 9, 23, 83-87 | Current CORS implementation | | `/backend/src/settings.rs` | 13-28, 75-90 | Settings structure | | `/backend/src/middleware/rate_limit.rs` | 16-127 | Middleware pattern reference | | `/backend/settings/development.yaml` | 1-9 | Development configuration | | `/backend/settings/production.yaml` | 1-9 | Production configuration | | `/specs/001-modbus-relay-control/decisions.md` | 71-92 | Deployment architecture | ## Security Considerations ### Critical Points 1. **Never use wildcard `*` origin with credentials enabled** - browsers reject this 2. **Specific origins only in production** - wildcards increase attack surface 3. **Minimal methods and headers** - only expose what's needed by API 4. **1-hour max_age** - allows policy updates within reasonable timeframe 5. **Test with actual frontend** - CORS errors only appear in browser, not curl/Postman ### Attack Vectors Mitigated - **CSRF via foreign domains**: Specific origins prevent malicious sites from making requests - **Credential theft**: Credentials only sent to whitelisted origins - **Data exfiltration**: Restrictive CORS prevents unauthorized cross-origin reads ## User Decisions (2026-01-02) ### Q1: Configuration Approach **Decision**: **Option C (Hybrid)** - Configure origins, credentials, and max_age **Rationale**: Balance of simplicity and production needs. Methods/headers hardcoded based on API requirements. ### Q2: Production URL **Decision**: `https://REDACTED` **Usage**: Set in `production.yaml` as allowed origin ### Q3: Development Port **Decision**: Port 5173 (Vite's default) **Note**: Previous `frontend_url: http://localhost:3000` was leftover from Nuxt project. Update to 5173. ### Q4: Exposed Headers **Decision**: Not needed for v1.0 **Future**: May add in v1.1+ (e.g., `x-ratelimit-remaining`) ### Q5: CORS Disable Option **Decision**: No `enabled` flag **Approach**: Development.yaml will have permissive settings (`*` origin, all methods) ### Q6: Default Behavior **Decision**: Restrictive (fail-safe) when `cors:` section missing **Note**: Development.yaml explicitly sets permissive settings, so this only affects missing config ### Q7: Multiple Origins Support **Decision**: Nice to have, include support via `Vec` **Implementation**: Use array in YAML, trivial to support ### Q8: Specification Update **Decision**: Add new FR-023 for configurable CORS in production **Action**: Update `spec.md` with new functional requirement ## Implementation Decisions Summary **Configuration Structure**: ```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 { // Restrictive default (fail-safe) allowed_origins: vec![], allow_credentials: false, max_age_secs: 3600, } } } ``` **Development Configuration** (`development.yaml`): ```yaml cors: allowed_origins: - "*" # Permissive for local development allow_credentials: false max_age_secs: 3600 frontend_url: http://localhost:5173 # Updated from 3000 ``` **Production Configuration** (`production.yaml`): ```yaml cors: allowed_origins: - "https://REDACTED" allow_credentials: true # Required for Authelia authentication max_age_secs: 3600 frontend_url: "https://REDACTED" ``` **Hardcoded Settings** (in code, not configurable): - **Methods**: GET, POST, PUT (based on API requirements) - **Allowed Headers**: content-type, authorization (minimum for API) - **Exposed Headers**: None for v1.0 ## Next Steps 1. ✅ **Clarify ambiguities** - Complete 2. ✅ **Choose approach** - Option C (Hybrid) 3. **Design architecture** with multiple implementation approaches 4. **Get user approval** on preferred approach 5. **Generate implementation plan** with tasks 6. **Review plan** for completeness ## References - Poem CORS documentation: Inferred from codebase patterns - CORS specification: MDN Web Docs (Cross-Origin Resource Sharing) - Project constitution: `specs/constitution.md` v1.1.0 - Deployment decisions: `specs/001-modbus-relay-control/decisions.md`