Files
sta/specs/001-modbus-relay-control/research-cors.md

466 lines
14 KiB
Markdown
Raw Normal View History

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