2026-01-01 23:29:31 +01:00
# CORS Configuration Guide
2026-05-15 10:01:36 +02:00
**Last Updated** : 2026-01-23
**Related Tasks** : T009-T016
**Status** : Complete (Phase 0.5)
2026-01-01 23:29:31 +01:00
## 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
2026-05-15 10:01:36 +02:00
Located in `backend/src/settings/cors.rs` :
2026-01-01 23:29:31 +01:00
```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
2026-05-15 10:01:36 +02:00
**Hardcoded in Implementation** :
2026-01-01 23:29:31 +01:00
- **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
2026-05-15 10:01:36 +02:00
**File** : `backend/settings/production.yaml`
2026-01-01 23:29:31 +01:00
```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
2026-05-15 10:01:36 +02:00
The `CorsSettings` struct is part of the settings module. Settings are loaded with `#[serde(default)]` to ensure backward compatibility: if the `cors` section is missing from YAML, it uses the restrictive `Default` implementation.
2026-01-01 23:29:31 +01:00
### 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.
2026-05-15 10:01:36 +02:00
**Enforcement** : The `From<CorsSettings> for Cors` implementation panics during startup if this constraint is violated:
2026-01-01 23:29:31 +01:00
```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)
2026-05-15 10:01:36 +02:00
**Cause** : Backend not allowing OPTIONS method.
2026-01-01 23:29:31 +01:00
2026-05-15 10:01:36 +02:00
**Solution** : The `From<CorsSettings> for Cors` trait implementation hardcodes OPTIONS in the allowed methods:
2026-01-01 23:29:31 +01:00
```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
2026-05-15 10:01:36 +02:00
**Cause**: Custom headers not in allowed list.
2026-01-01 23:29:31 +01:00
2026-05-15 10:01:36 +02:00
**Current Allowed Headers**:
2026-01-01 23:29:31 +01:00
- ` content-type` (for JSON request bodies)
- ` authorization` (for Authelia authentication tokens)
2026-05-15 10:01:36 +02:00
**Adding Custom Headers**: Requires modifying the ` From<CorsSettings> for Cors` trait implementation.
2026-01-01 23:29:31 +01:00
## 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 |
|------|---------|
2026-05-15 10:01:36 +02:00
| ` backend/src/settings/cors.rs` | ` CorsSettings` struct definition |
| ` backend/settings/base.yaml` | Baseline configuration |
2026-01-01 23:29:31 +01:00
| ` backend/settings/development.yaml` | Development CORS (permissive) |
2026-05-15 10:01:36 +02:00
| ` backend/settings/production.yaml` | Production CORS (restrictive) |
## Completed Tasks
All CORS configuration tasks (T009-T016) have been implemented and tested:
2026-01-01 23:29:31 +01:00
2026-05-15 10:01:36 +02:00
### T009-T010: CorsSettings Struct (Phase 0.5)
- 5 unit tests written (TDD approach) and the ` CorsSettings` struct implemented with fail-safe defaults
- Located in ` backend/src/settings/cors.rs`
2026-01-01 23:29:31 +01:00
2026-05-15 10:01:36 +02:00
### T011: Development YAML Configuration
- Added ` cors:` section with wildcard origin and ` allow_credentials: false`
- Updated ` frontend_url` to ` http://localhost:5173` (Vite default)
- File: ` backend/settings/development.yaml`
2026-01-01 23:29:31 +01:00
2026-05-15 10:01:36 +02:00
### T012: Production YAML Configuration
- Added ` cors:` section with specific origin and ` allow_credentials: true`
- File: ` backend/settings/production.yaml`
2026-01-01 23:29:31 +01:00
2026-05-15 10:01:36 +02:00
### T013-T014: Cors Middleware Implementation
- 6 unit tests written for the ` From<CorsSettings> for Cors` trait
- Implemented the conversion trait in ` backend/src/settings/cors.rs`
- Validates wildcard + credentials constraint (panics on misconfiguration)
- Hardcodes methods (GET, POST, PUT, PATCH, DELETE, OPTIONS)
- Hardcodes headers (content-type, authorization)
- Adds structured logging
2026-01-01 23:29:31 +01:00
2026-05-15 10:01:36 +02:00
### T015: Middleware Chain Integration
- Replaced ` Cors::new()` with ` Cors::from(settings.cors)` in startup.rs
- CORS applied after rate limiting (order: RateLimit → CORS → Data)
2026-01-01 23:29:31 +01:00
### T016: Integration Tests
2026-05-15 10:01:36 +02:00
- 9 comprehensive integration tests in ` backend/tests/cors_test.rs`
- Covers: preflight requests, actual request headers, max-age, credentials, methods, wildcard, multiple origins, unauthorized origin rejection
2026-01-01 23:29:31 +01:00
## 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 |
2026-05-15 10:01:36 +02:00
| 2026-01-22 | T013-T014 | ` From<CorsSettings> for Cors` trait implemented |
| 2026-01-22 | T015 | CORS middleware integrated into startup chain |
| 2026-01-22 | T016 | 9 integration tests written and passing |
2026-01-01 23:29:31 +01:00
---
2026-05-15 10:01:36 +02:00
**Maintainer Notes** : This configuration follows the project's **Type-Driven Development (TyDD)** and **Test-Driven Development (TDD)** principles. Tests were written first (T009, T013), then implementations were created to pass those tests. The CORS feature is fully implemented and tested across all environments.