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.
466 lines
14 KiB
Markdown
466 lines
14 KiB
Markdown
# 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**:
|
|
```rust
|
|
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**:
|
|
```rust
|
|
// 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:
|
|
|
|
```rust
|
|
#[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**:
|
|
```yaml
|
|
# 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:
|
|
|
|
```rust
|
|
#[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**:
|
|
```yaml
|
|
# 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:
|
|
|
|
```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!["*".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**:
|
|
```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 {
|
|
// Restrictive default (fail-safe)
|
|
allowed_origins: vec![],
|
|
allow_credentials: false,
|
|
max_age_secs: 3600,
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Development Configuration** (`development.yaml`):
|
|
```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`):
|
|
```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`
|