---
status: accepted
date: 2026-02-03
decision-makers: Constructor Fabric Steering Committee
---

# CORS — Built-in Handler with Per-Upstream/Route Configuration


<!-- toc -->

- [Context and Problem Statement](#context-and-problem-statement)
- [Decision Drivers](#decision-drivers)
- [Considered Options](#considered-options)
- [Decision Outcome](#decision-outcome)
  - [Consequences](#consequences)
  - [Confirmation](#confirmation)
- [Pros and Cons of the Options](#pros-and-cons-of-the-options)
  - [Proxy CORS to upstream](#proxy-cors-to-upstream)
  - [CORS as guard plugin](#cors-as-guard-plugin)
  - [Built-in CORS handler](#built-in-cors-handler)
- [More Information](#more-information)
- [Configuration Schema](#configuration-schema)
  - [Upstream/Route CORS Field](#upstreamroute-cors-field)
  - [Configuration Examples](#configuration-examples)
- [Implementation Details](#implementation-details)
  - [Preflight Request Handling](#preflight-request-handling)
  - [Actual Request Handling](#actual-request-handling)
  - [Origin Matching](#origin-matching)
  - [Security Considerations](#security-considerations)
- [Hierarchical Configuration](#hierarchical-configuration)
- [Error Responses](#error-responses)
- [Traceability](#traceability)

<!-- /toc -->

**ID**: `cpt-cf-oagw-adr-cors`

## Context and Problem Statement

OAGW proxies requests to upstream services. When web browsers make cross-origin requests, CORS policies are enforced. Without proper CORS support, browser-based clients cannot use OAGW. Key challenges: CORS is browser-specific, preflight OPTIONS must be handled locally (not proxied), CORS headers vary per upstream, credential-bearing requests require strict origin matching, and overly permissive CORS exposes APIs to unauthorized origins.

## Decision Drivers

* Support browser-based clients (SPAs, web apps)
* Security: prevent unauthorized cross-origin access
* Flexibility: per-upstream/route CORS configuration
* Performance: minimize preflight overhead
* Standards compliance: RFC 6454, WHATWG Fetch spec

## Considered Options

* Proxy CORS to upstream (passthrough)
* CORS as guard plugin
* Built-in CORS handler (first-class feature)

## Decision Outcome

Chosen option: "Built-in CORS handler", because it provides fast preflight handling, works when upstream is unavailable, and avoids plugin ordering complexity.

**Rationale**:

1. **Performance**: Preflight handled immediately, no upstream round-trip
2. **Reliability**: Works even if upstream is down or doesn't support CORS
3. **Simplicity**: Clear configuration model, no plugin ordering complexity
4. **Security**: OAGW-level origin enforcement before request reaches upstream
5. **Standards**: Proper CORS implementation per WHATWG Fetch spec

Options 1 and 2 rejected:

- Option 1: Adds latency, fails if upstream unavailable
- Option 2: Plugin ordering issues, more complex configuration

CORS is a first-class field in upstream/route configuration:

```json
{
  "cors": {
    "enabled": true,
    "allowed_origins": ["https://app.example.com"],
    "allowed_methods": ["GET", "POST"],
    "expose_headers": ["X-Request-ID"],
    "allow_credentials": true
  }
}
```

**Security defaults**: CORS disabled unless explicitly enabled. No regex patterns for origin matching (prevents bypasses). Port-sensitive and protocol-sensitive matching. Cannot use `allow_credentials` with wildcard origin. `Vary: Origin` always included to prevent cache poisoning. Preflight requests bypass per-request auth and plugin checks but remain subject to infrastructure-level controls (global/edge rate limiting, WAF/DDoS protection).

**Hierarchical configuration**: Follows sharing modes. With `inherit`, child origins are unioned with parent origins. With `enforce`, child cannot add origins. See [Hierarchical Configuration](#hierarchical-configuration) section for details.

### Consequences

* Good, because browser-based clients can use OAGW
* Good, because fast preflight handling (no upstream round-trip)
* Good, because works when upstream unavailable
* Good, because secure by default (disabled unless configured)
* Good, because standards-compliant implementation
* Bad, because adds built-in logic to OAGW core
* Bad, because requires CORS configuration for each upstream/route needing browser access
* Neutral, because CORS only affects browser clients (other clients unaffected)
* Neutral, because configuration complexity is proportional to security requirements
* Neutral, because preflight requests bypass per-request auth/plugin checks (by design) but still pass through global/edge rate limiting and WAF/DDoS controls

### Confirmation

Integration tests verify: preflight returns permissive 204 with echoed CORS headers, actual requests include CORS response headers for allowed origins, disallowed origins are rejected with 403 on actual requests, disallowed methods are rejected with 403 on actual requests, `allow_credentials` with wildcard origin is rejected at validation time.

## Pros and Cons of the Options

### Proxy CORS to upstream

Forward all requests including OPTIONS to upstream.

* Good, because simple (no OAGW logic), upstream controls CORS
* Bad, because preflight adds round-trip latency
* Bad, because upstream may not support CORS
* Bad, because cannot enforce OAGW-level origin restrictions
* Bad, because preflight fails if upstream unavailable

### CORS as guard plugin

Implement CORS as a configurable guard plugin.

* Good, because flexible per-upstream/route configuration
* Good, because local preflight handling
* Good, because can enforce stricter CORS than upstream
* Good, because works even if upstream is down
* Bad, because plugin ordering issues (must run before all other plugins)
* Bad, because more complex configuration

### Built-in CORS handler

CORS as first-class feature handled before plugin chain.

* Good, because preflight handled before routing/auth/plugins (fast path)
* Good, because clear configuration model, no plugin ordering issues
* Good, because standards-compliant default behavior
* Bad, because adds built-in logic, not extensible via plugins (but can be overridden)

## More Information

| Criteria | Proxy | Plugin | Built-in |
|---|:---:|:---:|:---:|
| Preflight latency | High | Low | Low |
| Configuration complexity | None | Medium | Low |
| Upstream independence | No | Yes | Yes |
| Plugin ordering issues | N/A | Yes | No |
| Standards compliance | Depends | Yes | Yes |
| Works when upstream down | No | Yes | Yes |

- [MDN: CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
- [WHATWG Fetch Standard](https://fetch.spec.whatwg.org/#http-cors-protocol)
- [RFC 6454: Origin](https://datatracker.ietf.org/doc/html/rfc6454)
- [OWASP: CORS Security](https://owasp.org/www-community/attacks/CORS_OriginHeaderScrutiny)

## Configuration Schema

### Upstream/Route CORS Field

```json
{
  "cors": {
    "type": "object",
    "properties": {
      "enabled": {
        "type": "boolean",
        "default": false,
        "description": "Enable CORS for this upstream/route"
      },
      "allowed_origins": {
        "type": "array",
        "items": { "type": "string", "format": "uri" },
        "description": "Allowed origins. Use ['*'] for any origin (not recommended with credentials)."
      },
      "allowed_methods": {
        "type": "array",
        "items": { "type": "string", "enum": [ "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS" ] },
        "default": [ "GET", "POST" ],
        "description": "Allowed HTTP methods"
      },
      "expose_headers": {
        "type": "array",
        "items": { "type": "string" },
        "default": [ ],
        "description": "Headers exposed to browser (beyond CORS-safelisted headers)"
      },
      "allow_credentials": {
        "type": "boolean",
        "default": false,
        "description": "Allow credentials (cookies, auth headers). Requires specific origins (not '*')."
      }
    },
    "required": [ "enabled" ]
  }
}
```

### Configuration Examples

**Public API (no credentials)**:

```json
{
  "cors": {
    "enabled": true,
    "allowed_origins": [ "*" ],
    "allowed_methods": [ "GET", "POST" ]
  }
}
```

**Authenticated API (with credentials)**:

```json
{
  "cors": {
    "enabled": true,
    "allowed_origins": [
      "https://app.example.com",
      "https://admin.example.com"
    ],
    "allowed_methods": [ "GET", "POST", "PUT", "DELETE" ],
    "expose_headers": [ "X-Request-ID", "X-RateLimit-Remaining" ],
    "allow_credentials": true
  }
}
```

**Development (localhost)**:

```json
{
  "cors": {
    "enabled": true,
    "allowed_origins": [
      "http://localhost:3000",
      "http://localhost:5173"
    ],
    "allowed_methods": [ "GET", "POST", "PUT", "DELETE" ],
    "allow_credentials": true
  }
}
```

## Implementation Details

### Preflight Request Handling

Browser preflight requests contain no credentials (per WHATWG Fetch spec), so no tenant context is available for upstream resolution. The proxy handler detects preflight and returns a permissive 204 that echoes back the requested origin, method, and headers. Origin and method validation is deferred to the actual request.

```text
OPTIONS /api/oagw/v1/proxy/api.example.com/users
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization

↓

1. Handler detects CORS preflight (OPTIONS + Origin + Access-Control-Request-Method)
2. Return 204 No Content echoing the request's origin, method, and headers
   (no upstream resolution, no tenant context required)
```

**Response headers** (preflight):

```http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
```

Origin enforcement happens on the subsequent actual request — after upstream resolution, the origin is validated against the upstream's CORS config before the request is forwarded. See [Actual Request Handling](#actual-request-handling).

### Actual Request Handling

```text
POST /api/oagw/v1/proxy/api.example.com/users
Origin: https://app.example.com
Content-Type: application/json

↓

1. Resolve upstream (requires tenant context from authenticated request)
2. Check: Is origin in allowed_origins? (403 if not)
3. Check: Is method in allowed_methods? (403 if not)
4. Forward request to upstream
5. Add CORS headers to response
```

**Response headers** (actual request):

```http
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: X-Request-ID
Access-Control-Allow-Credentials: true
Vary: Origin
```

### Origin Matching

**Exact match**:

```json
"allowed_origins": [ "https://app.example.com" ]
```

Matches: `https://app.example.com`
Rejects: `https://evil.com`, `https://app.example.com:8080`, `http://app.example.com`

**Wildcard** (discouraged with credentials):

```json
"allowed_origins": [ "*" ]
```

Matches: Any origin
Note: Cannot use with `allow_credentials: true`

**Multiple origins**:

```json
"allowed_origins": [
"https://app.example.com",
"https://admin.example.com"
]
```

### Security Considerations

**Deny by default**: CORS disabled unless explicitly enabled.

**Strict origin validation**:

- No regex patterns (prevents bypasses like `https://evil.com.example.com`)
- Port-sensitive matching (`:443` ≠ `:8443`)
- Protocol-sensitive (HTTP ≠ HTTPS)

**Credentials restriction**:

```rust
if config.allow_credentials && config.allowed_origins.iter().any(|o| o == "*") {
    return Err(CorsError::InvalidConfig(
        "Cannot use allow_credentials with wildcard origin".into(),
    ));
}
```

**Preflight optimization**:

- Return permissive 204 No Content at handler level (no upstream resolution, no body)
- Origin validation happens on the actual request (after upstream resolution, before forwarding)
- Skip per-request auth/plugin checks for preflight (global/edge rate limiting and WAF/DDoS controls still apply)

**Vary header**:
Always include `Vary: Origin` to prevent cache poisoning.

## Hierarchical Configuration

CORS configuration follows sharing modes:

```json
{
  "cors": {
    "sharing": "inherit",
    "enabled": true,
    "allowed_origins": [ "https://app.example.com" ]
  }
}
```

**Merge behavior**:

- Parent: `allowed_origins: ["https://app.example.com"]`
- Child: `allowed_origins: ["https://admin.example.com"]`
- Effective: `["https://app.example.com", "https://admin.example.com"]` (union)

With `sharing: enforce`, child cannot add origins.

## Error Responses

**Origin not allowed** (actual request — disallowed origin rejected before forwarding to upstream):

```http
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1",
  "title": "CORS Origin Not Allowed",
  "status": 403,
  "detail": "Origin 'https://evil.com' not in allowed origins list"
}
```

**Method not allowed** (actual request — disallowed method rejected before forwarding to upstream):

```http
HTTP/1.1 403 Forbidden

{
  "type": "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1",
  "title": "CORS Method Not Allowed",
  "status": 403,
  "detail": "Method 'DELETE' not in allowed methods list"
}
```

## Traceability

- **PRD**: [PRD.md](../PRD.md)
- **DESIGN**: [DESIGN.md](../DESIGN.md)

This decision directly addresses the following requirements or design elements:

* `cpt-cf-oagw-nfr-input-validation` — CORS origin validation as part of request validation
* `cpt-cf-oagw-fr-header-transform` — CORS headers added to responses
* `cpt-cf-oagw-fr-hierarchical-config` — CORS configuration follows sharing modes
