Cross-Origin Resource Sharing (CORS)

In the early days of the web, the ability to manipulate web page elements using JavaScript opened a Pandora’s Box of security vulnerabilities. Malicious scripts loaded on one page could potentially access and interact with elements on another page from a different origin, leading to data breaches.

Example: Imagine a scenario where a user is tricked into visiting a malicious website. This website loads a script that attempts to interact with a banking website that the user happens to be logged into in another browser tab. Without security measures in place, this malicious script could potentially access sensitive financial information from the banking website.

The Origin Barrier

To understand this issue, we need to define origin.  An origin represents the combination of a website’s protocol (e.g., http or https), hostname (e.g., www.example.com), and port number (if specified). Two URLs share the same origin if all three components match.

graph LR
    subgraph URL[https://.example.com:443/path/page.html]
        P[Protocol]
        H[Hostname]
        PT[Port]
        PA[Path]
    end

    P --> |https://| O
    H --> |example.com| O
    PT --> |:443| O

    O[Origin]

    PA --> |/path/page.html| NP[Not part of Origin]

    classDef urlPart fill:#f9f,stroke:#333,stroke-width:2px;
    classDef origin fill:#ff9,stroke:#3a33,stroke-width:4px;
    classDef notOrigin fill:#e6e6e6,stroke:#333,stroke-width:2px;

    class P,H,PT urlPart;
    class O origin;
    class PA,NP notOrigin;

    style URL fill:#f0f0f0,stroke:#333,stroke-width:2px;

A request that crosses origin boundaries is known as a cross-origin request.

JavaScript
function compareOrigins(url1, url2) {
    const parseUrl = (url) => {
        const a = document.createElement('a');
        a.href = url;
        return {
            protocol: a.protocol,
            hostname: a.hostname,
            port: a.port || (a.protocol === 'https:' ? '443' : '80')
        };
    };

    const origin1 = parseUrl(url1);
    const origin2 = parseUrl(url2);

    const sameOrigin = origin1.protocol === origin2.protocol &&
                       origin1.hostname === origin2.hostname &&
                       origin1.port === origin2.port;

    console.log(`URL 1: ${url1}`);
    console.log(`URL 2: ${url2}`);
    console.log(`Same origin: ${sameOrigin}`);
    console.log('---');

    return sameOrigin;
}

// Example usage
compareOrigins('https://example.com/page1', 'https://example.com/page2');
compareOrigins('https://example.com', 'http://example.com');
compareOrigins('https://api.example.com', 'https://example.com');
compareOrigins('https://example.com', 'https://example.com:8080');
compareOrigins('https://example.com', 'https://example.org');

Same-Origin Policy (SOP)

To mitigate the risks of cross-origin interactions, browsers implement the Same-Origin Policy (SOP).  This security policy restricts how scripts loaded from one origin can interact with resources from a different origin.  In essence, it enforces a “sandbox” for web pages, limiting their ability to access data from other sites.

graph LR
    subgraph Browser
        Page[Web Page<br/>example.com/page.html]
        SOP{Same-Origin<br/>Policy}
    end

    subgraph Server[example.com]
        Image[Image<br/>example.com/image.jpg]
    end

    Page -->|Request| SOP
    SOP -->|Allow| Image
    Image -->|Response| Page

    classDef page fill:#f9f,stroke:#333,stroke-width:2px;
    classDef policy fill:#ff9,stroke:#333,stroke-width:2px;
    classDef resource fill:#9ff,stroke:#333,stroke-width:2px;
    classDef container fill:#f0f0f0,stroke:#333,stroke-width:2px;

    class Page page;
    class SOP policy;
    class Image resource;
    class Browser,Server container;

    style SOP stroke-dasharray: 5 5

Cross-Origin Resource Sharing (CORS)

While the SOP is crucial for security, it also creates limitations in modern web development where interactions between different domains are often necessary. Cross-Origin Resource Sharing (CORS) provides a mechanism to selectively relax the SOP, enabling controlled cross-origin communication.

CORS uses HTTP headers to signal whether a server allows cross-origin requests from specific origins. The browser, acting as the intermediary, enforces these rules, preventing unauthorized cross-origin interactions.

CORS handles two types of requests:  simple requests and preflight requests.

Simple Requests

A simple request meets certain criteria:

  • It uses standard HTTP methods (GET, POST, HEAD).
  • It only includes specific headers.
  • The Content-Type header is limited to certain values (e.g., text/plain, application/x-www-form-urlencoded).

Here’s how a simple CORS request works:

  1. The client (e.g., a web page) sends a request to a server on a different origin. The request includes the client’s origin in the Origin header.
  2. The server responds, including the Access-Control-Allow-Origin header in its response. This header specifies which origins are allowed to access the requested resource.
  3. The browser compares the Origin header in the request with the Access-Control-Allow-Origin header in the response.  If they match, the browser allows the request to proceed.
sequenceDiagram
    participant Client as Client (Web Page)
    participant Browser as Browser Security Check
    participant Server as Server (Different Origin)

    Client->>Browser: Initiate cross-origin request
    Browser->>Server: GET request with Origin header<br/>(Origin: https://example.com)
    Server-->>Browser: Response with<br/>Access-Control-Allow-Origin header<br/>(Access-Control-Allow-Origin: https://example.com)
    Browser->>Browser: Compare Origin and<br/>Access-Control-Allow-Origin
    alt Headers match
        Browser->>Client: Allow request to proceed
    else Headers don't match
        Browser->>Client: Block request
    end
JavaScript
// Server-side code (Node.js with Express)
const express = require('express');
const app = express();

app.get('/data', (req, res) => {
  const clientOrigin = req.headers.origin;
  
  // Check if the client's origin is allowed
  if (clientOrigin === 'https://mydomain.com') {
    // Allow the request
    res.header('Access-Control-Allow-Origin', clientOrigin);
    res.json({ message: 'This is the requested data' });
  } else {
    // Deny the request
    res.status(403).json({ error: 'Origin not allowed' });
  }
});

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

Preflight Requests:  Asking Permission Before Proceeding

Requests that don’t meet the criteria for simple requests need to undergo a preflight check. This additional step is like requesting permission from the server before sending the actual request.  Preflight requests are often necessary when the request might modify data on the server.

Here’s how the preflight process works:

  1. The browser sends an OPTIONS request to the server, specifying the client’s origin in the Origin header and the intended HTTP method in the Access-Control-Request-Method header.
  2. The server responds, indicating which methods are allowed for the requested resource in the Access-Control-Allow-Methods header.
  3. If the requested method is allowed, the browser sends the actual request.

Client-Side Request:

JavaScript
// Client-side JavaScript (e.g., in a web browser)
fetch('https://api.example.com/data', {
  method: 'PUT', // Non-simple method
  headers: {
    'Content-Type': 'application/json', // Non-simple content type
    'X-Custom-Header': 'some value' // Custom header
  },
  body: JSON.stringify({ key: 'value' })
})
.then(response => {
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
})
.then(data => {
  console.log('Data received:', data);
})
.catch(error => {
  console.error('Error:', error);
});

// Note: The browser will automatically send the preflight OPTIONS request
// before this actual request. The preflight request will include:
// Access-Control-Request-Method: PUT
// Access-Control-Request-Headers: content-type, x-custom-header
// Origin: [your origin]

Server-Side Response:

JavaScript
// Server-side code (Node.js with Express)
const express = require('express');
const app = express();

// Middleware to handle CORS preflight requests
app.options('/data', (req, res) => {
  const allowedOrigin = 'https://mydomain.com';
  const requestOrigin = req.headers.origin;

  if (requestOrigin === allowedOrigin) {
    res.header('Access-Control-Allow-Origin', requestOrigin);
    res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
    res.header('Access-Control-Allow-Headers', 'Content-Type, X-Custom-Header');
    res.header('Access-Control-Max-Age', '86400'); // 24 hours
    res.sendStatus(204); // No content
  } else {
    res.sendStatus(403); // Forbidden
  }
});

// Actual route handler
app.put('/data', (req, res) => {
  const allowedOrigin = 'https://mydomain.com';
  const requestOrigin = req.headers.origin;

  if (requestOrigin === allowedOrigin) {
    res.header('Access-Control-Allow-Origin', requestOrigin);
    res.json({ message: 'PUT request successful' });
  } else {
    res.sendStatus(403); // Forbidden
  }
});

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

Key Point:  To minimize overhead, preflight responses can be cached by the browser for a specified duration, reducing the need for repeated checks. The server can control this caching behavior using the Access-Control-Max-Age header.

Authentication and Preflight Requests

By default, authentication credentials (e.g., cookies) are not included in CORS requests, both simple and preflight. To enable this, the client must set the withCredentials flag to true in its request. The server, in its response, must also include the Access-Control-Allow-Credentials header set to true to indicate that it accepts credentialed requests from the specified origin.

sequenceDiagram
    participant Client as Client (Web Page)
    participant Browser as Browser
    participant Server as Server (Different Origin)

    Client->>Browser: Set withCredentials = true
    
    Note over Browser,Server: Preflight Request
    Browser->>Server: OPTIONS /resource<br/>Origin: https://example.com<br/>Access-Control-Request-Method: POST
    Server-->>Browser: HTTP/1.1 200 OK<br/>Access-Control-Allow-Origin: https://example.com<br/>Access-Control-Allow-Credentials: true<br/>Access-Control-Allow-Methods: POST

    Note over Browser,Server: Main Request
    Browser->>Server: POST /resource<br/>Origin: https://example.com<br/>Cookie: session=abc123
    Server-->>Browser: HTTP/1.1 200 OK<br/>Access-Control-Allow-Origin: https://example.com<br/>Access-Control-Allow-Credentials: true

    Browser->>Client: Return response

CORS Vulnerabilities:  The Perils of Misconfiguration

While CORS is a valuable mechanism, improper implementation can introduce vulnerabilities:

  • Overly Permissive Configurations:  Allowing requests from any origin (*) or broad wildcard patterns can expose APIs to malicious actors.
  • Trusting Vulnerable Origins: If an API trusts an origin that itself has security vulnerabilities (e.g., susceptible to XSS), attackers might exploit this to gain access to the trusted API.

Preventing CORS Vulnerabilities: Best Practices

Secure CORS implementation relies on a combination of careful configuration and adhering to best practices:

  • Validate Origins Rigorously:  Avoid overly permissive configurations.  Allow only specific, trusted origins.
  • Minimize Wildcard Usage:  Avoid using wildcard patterns (*) whenever possible.  Specify exact origins and methods.
  • Enforce Server-Side Validation: CORS should not replace server-side input validation. The server must independently validate all data received, protecting against potential bypasses of client-side checks.

Think About It: Is a simple DELETE request to an API likely to require preflighting?