Files
sta/docs/cors-configuration.md
Lucien Cartier-Tilet e98b51c2ea 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)
2026-01-11 00:39:19 +01:00

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

  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):

#[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

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):

  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:

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: 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:

# ✅ 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:

  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:

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:

  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:
    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"
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

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.