Files
sta/specs/001-modbus-relay-control/research-cors.md
Lucien Cartier-Tilet cb33956043 docs(cors): add CORS configuration planning and tasks
Add comprehensive CORS planning documentation and task breakdown for
Phase 0.5 (8 tasks: T009-T016).

- Create research-cors.md with security analysis and decisions
- Add FR-022a to spec.md for production CORS requirements
- Update tasks.md: 94 → 102 tasks across 9 phases
- Document CORS in README and plan.md

Configuration approach: hybrid (configurable origins/credentials,
hardcoded methods/headers) with restrictive fail-safe defaults.
2026-01-11 00:39:19 +01:00

14 KiB

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:

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<Method>)           // Multiple methods
    .allow_header(header: HeaderName)              // Request header
    .allow_headers(headers: Vec<HeaderName>)       // Multiple headers
    .expose_header(header: HeaderName)             // Response header
    .expose_headers(headers: Vec<HeaderName>)      // 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:

// 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);

Option A: Minimal Configuration (MVP)

Add simple CORS settings that cover 80% of use cases:

#[derive(Debug, serde::Deserialize, Clone)]
pub struct CorsSettings {
    pub allowed_origins: Vec<String>,
    pub allow_credentials: bool,
}

impl Default for CorsSettings {
    fn default() -> Self {
        Self {
            allowed_origins: vec!["*".to_string()],
            allow_credentials: false,
        }
    }
}

YAML Configuration:

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

#[derive(Debug, serde::Deserialize, Clone)]
pub struct CorsSettings {
    pub enabled: bool,
    pub allowed_origins: Vec<String>,
    pub allowed_methods: Vec<String>,
    pub allowed_headers: Vec<String>,
    pub expose_headers: Vec<String>,
    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:

# 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

Combine simplicity with essential production features:

#[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!["*".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<String> 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:

#[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 {
            // Restrictive default (fail-safe)
            allowed_origins: vec![],
            allow_credentials: false,
            max_age_secs: 3600,
        }
    }
}

Development Configuration (development.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):

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