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.
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_urlexists inbackend/src/settings.rs:20but 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:
- Configuration struct with settings
- Middleware implementation using configuration
- Conditional instantiation in
startup.rsbased on settings - YAML configuration in
settings/*.yaml - 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);
Recommended Approach
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
Option C: Hybrid Approach (Recommended)
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
-
backend/src/settings.rs- Add
CorsSettingsstruct (afterRateLimitSettings) - Add
cors: CorsSettingsfield toSettingsstruct - Add
#[serde(default)]attribute for backward compatibility
- Add
-
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))
-
backend/settings/development.yaml- Add
cors:section with permissive development settings - Allow localhost:3000 and 127.0.0.1:3000
- Add
-
backend/settings/production.yaml- Add
cors:section with restrictive production settings - Use actual Cloudflare Pages URL
- Set
allow_credentials: true
- Add
-
specs/001-modbus-relay-control/plan.md- Update technical context with CORS configuration
- Add CORS configuration task to implementation plan
-
CLAUDE.md(optional)- Document CORS configuration in project instructions
Testing Strategy
- Unit Tests: Verify
build_cors()creates correct PoemCorsinstance - Integration Tests: Verify CORS headers in HTTP responses
- 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
- Never use wildcard
*origin with credentials enabled - browsers reject this - Specific origins only in production - wildcards increase attack surface
- Minimal methods and headers - only expose what's needed by API
- 1-hour max_age - allows policy updates within reasonable timeframe
- 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
- ✅ Clarify ambiguities - Complete
- ✅ Choose approach - Option C (Hybrid)
- Design architecture with multiple implementation approaches
- Get user approval on preferred approach
- Generate implementation plan with tasks
- 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.mdv1.1.0 - Deployment decisions:
specs/001-modbus-relay-control/decisions.md