CORS

Configure Cross-Origin Resource Sharing (CORS) policies to control browser access from different origins.

Full source

File: pwsh/tutorial/examples/15.8-Cors-Multipolicy.ps1

<#
    Sample:  OpenAPI 3.1.0 sample script demonstrating CORS policies with multiple policies in a Kestrun API server.
    Purpose: To demonstrate how to configure and test multiple CORS policies in a multi-API scenario.
    File:    15.8-Cors-MultiPolicy.ps1
    Notes:   This script shows how to configure CORS policies for different API endpoints and test browser CORS behaviors.
             It includes sample functions for listing products, getting product details by ID, retrieving partner inventory levels,
             creating and deleting orders, and a UI server for testing CORS behaviors.
#>

param(
    [int]$Port = 5000,
    [IPAddress]$IPAddress = [IPAddress]::Loopback,
    [string]$PartnerOrigin = 'http://localhost:5000'
)

if (-not (Get-Module Kestrun)) { Import-Module Kestrun }
# ---------------------------------------------------------------------------
# Create servers (adjust these to your actual Kestrun host factory cmdlets)
# ---------------------------------------------------------------------------

New-KrLogger | Add-KrSinkConsole |
    Set-KrLoggerLevel -Value Debug |
    Register-KrLogger -Name 'console' -SetAsDefault
New-KrServer -Name 'Swagger Petstore - OpenAPI 3.1'
Add-KrEndpoint -Port $Port -IPAddress $IPAddress

# Align the UI/API origins to the active listener port so CORS policies match test requests.
# Use 'localhost' to match what test clients typically send as Origin header.
$ApiPort = $Port
$uiOrigin = "http://localhost:$ApiPort"
# Expose OpenAPI (adjust to your OpenAPI enablement)
Add-KrOpenApiInfo -Title 'CORS Multi-Policy Sample' -Version '1.0.0' `
    -Description 'Sample: Multiple CORS policies + multiple APIs + intentional browser CORS failures.'

Add-KrOpenApiServer -Url '/' -Description 'Local Kestrun Server'
Add-KrOpenApiTag -Name 'catalog' -Description 'Product catalog operations'
Add-KrOpenApiTag -Name 'partner' -Description 'Partner-specific operations'
Add-KrOpenApiTag -Name 'orders' -Description 'Order management operations'

# ---------------------------------------------------------------------------
# In-memory data
# ---------------------------------------------------------------------------
$Products = @(
    [ordered]@{ id = 1; name = 'Red Ball'; price = 9.99 }
    [ordered]@{ id = 2; name = 'Blue Bone'; price = 12.49 }
    [ordered]@{ id = 3; name = 'Green Mouse'; price = 4.95 }
)

# ===========================================================================
# 1) CORS policies (API server)
# ===========================================================================
# Default policy: allow the UI origin for GET only (keeps "no Cors metadata" interesting)
New-KrCorsPolicyBuilder |
    Set-KrCorsOrigin -Origins $uiOrigin |
    Set-KrCorsMethod -Methods GET |
    Set-KrCorsHeader -Any |
    Add-KrCorsPolicy -Default

# PublicRead: explicitly allow UI origin GET (catalog operations)
New-KrCorsPolicyBuilder |
    Set-KrCorsOrigin -Origins $uiOrigin |
    Set-KrCorsMethod -Methods GET |
    Set-KrCorsHeader -Any |
    Add-KrCorsPolicy -Name 'PublicRead'

# PartnerOnly: allow ONLY partner origin (UI origin will fail)
New-KrCorsPolicyBuilder |
    Set-KrCorsOrigin -Origins $PartnerOrigin |
    Set-KrCorsMethod -Methods GET |
    Set-KrCorsHeader -Any |
    Add-KrCorsPolicy -Name 'PartnerOnly'

# AdminWrite: allow UI origin, but only for write methods; triggers preflight from the UI
New-KrCorsPolicyBuilder |
    Set-KrCorsOrigin -Origins $uiOrigin |
    Set-KrCorsMethod -Methods POST, PUT, DELETE |
    Set-KrCorsHeader -Any |
    Add-KrCorsPolicy -Name 'AdminWrite'


[OpenApiSchemaComponent()]
class Order {
    [Guid]$orderId
    [int]$productId
    [DateTime]$created
}

Enable-KrConfiguration

# =========================================================
#                OPENAPI DOC ROUTE / BUILD
# =========================================================
Add-KrOpenApiRoute  # Default Pattern '/openapi/{version}/openapi.{format}'

Add-KrApiDocumentationRoute -DocumentType Swagger
Add-KrApiDocumentationRoute -DocumentType Redoc
Add-KrApiDocumentationRoute -DocumentType Scalar
Add-KrApiDocumentationRoute -DocumentType Rapidoc
Add-KrApiDocumentationRoute -DocumentType Elements

# ===========================================================================
# 2) API operations (operationId == function name)
#    Summary/description/params come from comment-based help.
# ===========================================================================

<#
.SYNOPSIS
    List available products.
.DESCRIPTION
    Returns the public product catalog.
    This operation is accessible cross-origin from approved UI origins.
#>
function listProducts {
    [OpenApiPath(HttpVerb = 'Get', Pattern = '/products', Tags = 'catalog', CorsPolicy = 'PublicRead')]
    [OpenApiResponse(StatusCode = '200', Description = 'Product list', ContentType = 'application/json')]
    param()
    Write-KrLog -Level Information -Message 'GET /products'
    Write-KrJsonResponse -StatusCode 200 -InputObject $Products
}

<#
.SYNOPSIS
    Get product details by ID.
.DESCRIPTION
    Returns details for a single product.
.PARAMETER productId
    The unique identifier of the product.
#>
function getProductById {
    [OpenApiPath(HttpVerb = 'Get', Pattern = '/products/{productId}', Tags = 'catalog', CorsPolicy = 'PublicRead')]
    [OpenApiResponse(StatusCode = '200', Description = 'Product details', ContentType = 'application/json')]
    [OpenApiResponse(StatusCode = '404', Description = 'Not found')]
    param(
        [OpenApiParameter(In = [OaParameterLocation]::Path)]
        [Parameter(Mandatory)]
        [int]$productId
    )
    Write-KrLog -Level Information -Message "GET /products/$productId"
    Expand-KrObject -InputObject $Products
    $item = $Products | Where-Object id -EQ $productId | Select-Object -First 1
    if (-not $item) { return Write-KrStatusResponse -StatusCode 404 }

    Write-KrJsonResponse -StatusCode 200 -InputObject $item
}

<#
.SYNOPSIS
    Retrieve partner inventory levels.
.DESCRIPTION
    Returns inventory information reserved for business partners.
    Requests from the UI origin will fail due to the PartnerOnly CORS policy.
#>
function getPartnerInventory {
    [OpenApiPath(HttpVerb = 'Get', Pattern = '/partner/inventory', Tags = 'partner', CorsPolicy = 'PartnerOnly')]
    [OpenApiResponse(StatusCode = '200', Description = 'Partner inventory list', ContentType = 'application/json')]
    param()
    Write-KrLog -Level Information -Message 'GET /partner/inventory'
    Write-KrJsonResponse -StatusCode 200 -InputObject @(
        [pscustomobject]@{ sku = 'RB-001'; qty = 100 }
        [pscustomobject]@{ sku = 'BB-002'; qty = 50 }
    )
}

<#
.SYNOPSIS
    Create a new order.
.DESCRIPTION
    Creates a new order for a given product ID.
    This operation requires a trusted UI origin and will trigger a CORS preflight request.
.PARAMETER Body
    JSON payload containing:
      - productId: integer
#>
function createOrder {
    [OpenApiPath(HttpVerb = 'Post', Pattern = '/orders', Tags = 'orders', CorsPolicy = 'AdminWrite')]
    [OpenApiResponse(StatusCode = '201', Description = 'Order created', SchemaRef = 'Order', ContentType = 'application/json')]
    [OpenApiResponse(StatusCode = '400', Description = 'Invalid request')]
    param(
        [OpenApiRequestBody(Required = $true, ContentType = ('application/json'))]
        [Int]$productId
    )
    Write-KrLog -Level Information -Message "POST /orders with productId $productId"
    if ( $productId -le 0) { Write-KrStatusResponse -StatusCode 400 }else {

        $created = [Order]@{
            orderId = [guid]::NewGuid()
            productId = [int]$productId
            created = Get-Date
        }

        Write-KrJsonResponse -StatusCode 201 -InputObject $created
    }
}

<#
.SYNOPSIS
    Delete an order.
.DESCRIPTION
    Deletes an existing order by ID.
    This operation is restricted by the AdminWrite CORS policy.
.PARAMETER orderId
    The unique identifier of the order.
#>
function deleteOrder {
    [OpenApiPath(HttpVerb = 'Delete', Pattern = '/orders/{orderId}', Tags = 'orders', CorsPolicy = 'AdminWrite')]

    [OpenApiResponse(StatusCode = '204', Description = 'Deleted')]
    param(
        [OpenApiParameter(Name = 'orderId', In = 'path', Required = $true)]
        [Guid]$orderId = 'f91aa45e-b733-49ed-a379-f79ea210fbd4'  # dummy value
    )
    Write-KrLog -Level Information -Message "DELETE /orders/$orderId"
    Write-KrStatusResponse -StatusCode 204
}

<#
.SYNOPSIS
    Endpoint without explicit CORS association.
.DESCRIPTION
    Demonstrates behavior when an operation has no Cors='...' metadata.
    The effective behavior depends on the default CORS policy configured on the server.
#>
function getNoCorsInfo {
    [OpenApiPath(HttpVerb = 'Get', Pattern = '/nocors', Tags = 'misc')]
    [OpenApiResponse(StatusCode = '200', Description = 'No CORS metadata', ContentType = 'application/json')]
    param()

    Write-KrJsonResponse -StatusCode 200 -InputObject @{
        ok = $true
        note = 'No Cors property was provided for this operation'
    }
}




# ===========================================================================
# 3) UI server: browser test page
# ===========================================================================

<#
.SYNOPSIS
    UI page for testing CORS behaviors.
.DESCRIPTION
    Serves an HTML page that performs cross-origin fetch calls to the API server.
#>
function corsTestUi {
    param()

    $indexHtml = @"
<!doctype html>
<html>
<head>
  <meta charset="utf-8"/>
  <title>Kestrun CORS Multi-Policy Demo</title>
  <style>
    body { font-family: sans-serif; margin: 20px; }
    button { margin: 4px; }
    pre { background: #111; color: #0f0; padding: 10px; overflow:auto; }
    code { background:#eee; padding:2px 4px; }
  </style>
</head>
<body>
  <h1>Kestrun CORS Multi-Policy Demo</h1>
  <p><b>UI Origin:</b> $uiOrigin</p>
  <p><b>API Origin:</b> http://localhost:$ApiPort</p>

  <div>
    <button onclick="callApi('GET', '/products')">GET /products (should succeed)</button>
    <button onclick="callApi('GET', '/partner/inventory')">GET /partner/inventory (should FAIL CORS)</button>
    <button onclick="callApi('POST', '/orders', { productId: 1 })">POST /orders (should succeed; preflight)</button>
    <button onclick="callApi('POST', '/orders', { foo: 'bar' })">POST /orders invalid body (CORS ok; API 400)</button>
    <button onclick="callApi('GET', '/nocors')">GET /nocors (depends on default policy)</button>
    <button onclick="openOpenApi()">OpenAPI JSON</button>
  </div>

  <h2>Result</h2>
  <pre id="out"></pre>

<script>
const apiBase = 'http://localhost:$ApiPort';

function log(msg) {
  document.getElementById('out').textContent = msg;
}

async function callApi(method, path, body) {
  const url = apiBase + path;

  try {
    const res = await fetch(url, {
      method,
      mode: 'cors',
      headers: body ? { 'content-type': 'application/json' } : undefined,
      body: body ? JSON.stringify(body) : undefined
    });

    const text = await res.text();
    log('HTTP ' + res.status + '\\n\\n' + text);
  } catch (e) {
    // CORS failures typically surface as a fetch TypeError/network error in the browser.
    log('FETCH ERROR (often CORS):\\n' + e);
  }
}

function openOpenApi() {
  window.open(apiBase + '/openapi/v1.json', '_blank');
}
</script>
</body>
</html>
"@

    Write-KrHtmlResponse -StatusCode 200 -Template $indexHtml
}



# ===========================================================================
# 4) Start server
# ===========================================================================
Start-KrServer


Step-by-step

  1. Logger: Register console logger with debug level to observe CORS-related requests.
  2. Server: Create server and configure OpenAPI metadata for the multi-policy sample.
  3. Default policy: Define a default CORS policy allowing UI origin for GET requests only.
  4. Named policies: Create three specialized policies:
    • PublicRead: Allows UI origin for catalog operations
    • PartnerOnly: Restricts access to partner origin only (UI will fail)
    • AdminWrite: Permits write operations (POST/DELETE) with preflight support
  5. API routes: Register OpenAPI operations with specific CORS policies via CorsPolicy parameter.
  6. UI test page: Add an HTML endpoint serving a browser-based test interface.
  7. Documentation routes: Enable OpenAPI spec and multiple documentation UIs.
  8. Start: Launch server and test CORS behaviors from the browser UI.

Try it

Start the server:

.\15.8-Cors-Multipolicy.ps1 -Port 5000

Open the test UI in a browser:

Start-Process "http://localhost:5000"

Test individual endpoints with curl (note: curl doesn’t enforce CORS, browsers do):

# List products (PublicRead policy allows this)
curl -i http://localhost:5000/products

# Get partner inventory (requires partner origin, curl bypasses CORS)
curl -i http://localhost:5000/partner/inventory

# Create order (AdminWrite policy, triggers preflight in browsers)
curl -i -X POST http://localhost:5000/orders `
  -H "Content-Type: application/json" `
  -d '{"productId":1}'

# Test endpoint without explicit CORS policy
curl -i http://localhost:5000/nocors

View the OpenAPI specification:

curl http://localhost:5000/openapi/v1/openapi.json

CORS concepts

Origins and policies

CORS policies control which origins can access your API from a browser. An origin consists of scheme, host, and port (e.g., http://localhost:5000).

Default policy: Applies to routes without explicit policy assignment.

Named policies: Create granular control for different route groups:

  • Read-only access for public catalog
  • Partner-specific access with different origin
  • Write operations requiring preflight validation

Preflight requests

Browsers send OPTIONS requests before certain cross-origin operations:

  • Non-simple methods (POST, PUT, DELETE)
  • Custom headers
  • Content-Type other than form-encoded

The AdminWrite policy demonstrates preflight handling for POST and DELETE operations.

You can reduce preflight frequency by setting a max age:

New-KrCorsPolicyBuilder |
  Set-KrCorsOrigin -Origins 'http://localhost:3000' |
  Set-KrCorsMethod -Methods GET, POST, DELETE |
  Set-KrCorsHeader -Any |
  Set-KrCorsPreflightMaxAge -Seconds 7200 |
  Add-KrCorsPolicy -Name 'WithMaxAge'

Policy builder

Use the fluent builder pattern to construct policies:

New-KrCorsPolicyBuilder |
    Set-KrCorsOrigin -Origins 'http://localhost:5000' |
    Set-KrCorsMethod -Methods GET, POST |
    Set-KrCorsHeader -Any |
    # Expose custom headers so browsers can read them
    Set-KrCorsExposedHeader -Headers 'X-Total-Count','X-Page-Number' |
    # Cache browser preflight for 2 hours
    Set-KrCorsPreflightMaxAge -Seconds 7200 |
    Add-KrCorsPolicy -Name 'MyPolicy'

Route assignment

Assign policies to individual operations:

function listProducts {
    [OpenApiPath(HttpVerb = 'Get', Pattern = '/products', CorsPolicy = 'PublicRead')]
    param()
    Write-KrJsonResponse -StatusCode 200 -InputObject $Products
}

Key points

  • Origin matching is exact: http://localhost:5000http://127.0.0.1:5000
  • CORS is browser-enforced: Tools like curl bypass CORS restrictions
  • Test with real browsers: Use the included UI test page to verify CORS behaviors
  • Preflight caching: Browsers cache preflight responses to reduce overhead
  • Default policy fallback: Routes without explicit policy use the default policy

Troubleshooting

CORS errors in browser console: Check that the Origin header matches allowed origins exactly.

Preflight failures: Ensure write operations (POST/PUT/DELETE) have appropriate policy allowing those methods.

Missing CORS headers: Verify Add-KrCorsPolicy was called with correct policy name.

Partner-only route fails: The PartnerOnly policy intentionally rejects UI origin to demonstrate policy isolation.

References


Previous / Next

Previous: Forwarded Headers Next: None