Implements CorsSettings struct with validation and deserialization support for configuring Cross-Origin Resource Sharing in the application settings. Ref: T010 (specs/001-modbus-relay-control)
16 KiB
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
- Origin Validation: Backend must explicitly allow requests from
https://sta.example.com - Credentials Support: Traefik + Authelia authentication requires
allow_credentials: true - Preflight Caching: Proper
max_agereduces unnecessary OPTIONS requests - Security: Restrictive defaults prevent unauthorized access
Configuration Structure
CorsSettings Struct
Located in backend/src/settings.rs (lines 217-232):
#[derive(Debug, serde::Deserialize, Clone)]
pub struct CorsSettings {
pub allowed_origins: Vec<String>,
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 headersmax_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_originsprevents accidental permissive CORS
Environment-Specific Configuration
Development Environment
File: backend/settings/development.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)
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):
#[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):
backend/settings/base.yaml(baseline settings)backend/settings/{environment}.yaml(development or production)- Environment variables with
APP__prefix (e.g.,APP__CORS__ALLOWED_ORIGINS)
Example Environment Variable Override:
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)
#[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)
#[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)
#[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)
#[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)
#[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
# 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:
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: falsemax_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:
# ✅ 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:
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
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:
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:
- Check
allowed_originsin YAML configuration - Verify frontend URL exactly matches (including protocol and port)
- 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:
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:
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:
- Check file exists:
backend/settings/development.yamlorproduction.yaml - Verify YAML syntax (use
yamllint) - Check environment:
echo $APP_ENVIRONMENT(should bedev,development,prod, orproduction) - Enable debug logging to see loaded configuration:
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:
[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_urltohttp://localhost:5173(Vite default)
T012: Create production.yaml
- Add
cors:section with restrictive settings - Use
https://sta.example.comas allowed origin - Set
allow_credentials: truefor Authelia
T013-T014: Implement build_cors() Function
- Create
build_cors(settings: &CorsSettings) -> Corsinstartup.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.rsline ~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-Originheader
References
Internal Documentation
- CORS Research Document - Complete research and decision log
- Feature Specification - FR-022a requirement
- Implementation Tasks - T009-T016 task breakdown
External Resources
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.