feat(settings): add CorsSettings struct for CORS configuration
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)
This commit is contained in:
539
docs/cors-configuration.md
Normal file
539
docs/cors-configuration.md
Normal file
@@ -0,0 +1,539 @@
|
||||
# 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<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 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.
|
||||
Reference in New Issue
Block a user