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.
This commit is contained in:
465
specs/001-modbus-relay-control/research-cors.md
Normal file
465
specs/001-modbus-relay-control/research-cors.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user