# CORS rules

Cross-origin resource sharing (CORS) is how a browser decides whether JavaScript on one website may
talk to another. When a page on `https://www.example.com` uses `fetch()` or an S3 SDK to reach
`https://s3.eu-central-1.strg.com`, that is a cross-origin request, and the browser lets the page
send it, or read the answer, only if the bucket's CORS rules allow that page's origin. Browsers do
this to protect the people using them; CORS rules are how a bucket tells the browser which websites
it is happy to hear from.

A bucket needs CORS rules when a browser application talks to it directly: a web app taking uploads
straight from the browser with presigned URLs, a page reading JSON or images into a script, a photo
editor drawing objects onto a canvas, or an S3 SDK running in the page. Plain `<img>`, `<video>` and
download links work without any, and so does everything your servers, scripts and command-line tools
do - CORS is a browser matter only.

CORS grants no access by itself. A private bucket stays private: every request to it still needs a
signature or a presigned URL. The rules only decide which websites' pages the browser lets send
those requests.

## What a browser gets

The S3 endpoint applies a bucket's rules to every request from a web page - signed requests,
presigned URLs and anonymous reads of a bucket whose *Bucket Protection* is off alike - and to error
answers as well as successful ones, so a page's script can read why something went wrong.

Here is what each kind of browser request receives:

| The browser sends | The S3 endpoint answers |
| --- | --- |
| A preflight (`OPTIONS`) a rule allows | `200`, with the `Access-Control-*` headers the rule grants - `Access-Control-Max-Age` from its `MaxAgeSeconds` |
| A preflight no rule allows - another origin, method or request header - or for a bucket that does not exist or is in another region | `403 AccessForbidden`, with no `Access-Control-*` headers. The answer does not say which |
| A request from an origin a rule allows | The answer, carrying the headers the rule grants: `Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials: true`, and `Access-Control-Expose-Headers` with the rule's `ExposeHeaders` |
| A request from an origin no rule allows | The answer, with no `Access-Control-*` headers - the browser keeps it from the page's script |

A change to the rules takes a little while to reach every browser. For a short time, a page may
still get the previous verdict:

| Answer | Previous verdict for up to |
| --- | --- |
| A preflight | The rule's `MaxAgeSeconds` - the browser reuses its answer that long. STRG asks your rules afresh on every preflight |
| An anonymous read of a bucket whose *Bucket Protection* is off, for an object whose `Cache-Control` lets a shared cache keep it | 5 minutes. STRG caches such answers, their `Access-Control-*` headers included, for at most 5 minutes, and never clears that cache early |
| An error answer, and the answer to an operation STRG answers itself, such as `CreateBucket`, `DeleteBucket` or `GetBucketLocation` | 1 minute |
| Any other answer to a signed or presigned request | No delay - it is not cached |

**Note:**

The object browser in the dashboard uploads and downloads whatever your rules say, so
`https://app.strg.com` never needs a rule of its own.

## The rule format

STRG uses S3's JSON format for CORS rules - the one the AWS console and documentation use - so an
existing AWS configuration pastes in as it is. A configuration is a list of up to 100 rules. When a
browser asks, the first rule that matches the page's origin and the request is the one that applies.

```json title="cors.json"
[
  {
    "AllowedOrigins": ["https://www.example.com"],
    "AllowedMethods": ["GET", "HEAD", "PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["etag"],
    "MaxAgeSeconds": 3600
  }
]
```

Each rule is made of these fields:

| Field | Required | What it means | Accepted values |
| --- | --- | --- | --- |
| `AllowedOrigins` | Yes, at least one | Which websites' pages may make the request | `*` for every website, alone in its list; `https` for every HTTPS website; or `scheme://host[:port]` with `http` or `https`, no path and no trailing slash. The host may be `*`, or start with one `*.` label, as in `https://*.example.com` |
| `AllowedMethods` | Yes, at least one | What those pages may do | `GET`, `HEAD`, `PUT`, `POST`, `DELETE` - in capitals. `GET` and `HEAD` read, `PUT` uploads, `POST` starts and completes multipart uploads, `DELETE` deletes |
| `AllowedHeaders` | No | Which request headers their scripts may send | Header names. `*` allows any, alone in its list; a trailing `*` matches a prefix, as in `x-amz-*` |
| `ExposeHeaders` | No | Which response headers their scripts may read - browsers hide the rest | Header names in full, no `*` |
| `MaxAgeSeconds` | No | How long, in seconds, a browser may reuse the answer before asking again | A whole number from 0 to 86,400 (one day). Without it, the answer is good for 5 seconds |
| `ID` | No | Welcome, so that AWS configurations paste in, and set aside rather than stored | Any text |

Any other field is an error, so a misspelt field name shows up right away rather than being quietly
ignored.

### What is stored

STRG stores your rules in a tidy, normalized form, and that form is what the dashboard and
[`GetBucketCors`](https://docs.strg.com/s3-api/GetBucketCors) return:

| You send | Stored and returned |
| --- | --- |
| `ID` | Removed |
| Origins and header names in any case | Lowercased |
| Methods in any order | Possibly reordered - the dashboard shows `GET`, `HEAD`, `PUT`, `POST`, `DELETE`; over the S3 API the order is not guaranteed, so methods are best compared as a set |
| An empty `AllowedHeaders` or `ExposeHeaders` list | Omitted |
| No `MaxAgeSeconds` | Omitted |

Code that checks what it saved does best to compare against the normalized form, rather than
against what it sent.

## Limits and checks

STRG checks each rule as it is saved, so a typo shows up right away rather than as a puzzling
browser error later. These are the limits a configuration lives within:

| Limit | Value |
| --- | --- |
| Rules per bucket | 100 |
| `MaxAgeSeconds` | 0-86,400 |
| Size of one rule | 1,000 bytes, counted as below |
| Origins per rule | `*` is the only origin when it is used; `https` cannot be listed beside any `https://` origin |
| `*` in an origin | At most one, and only as the whole host or its first label |
| `*` in an allowed header | Only as the last character, and a bare `*` is the only header when it is used |
| Duplicates | An origin, method or header listed twice in the same rule is an error. Origins and headers compare case-insensitively |

### The rule size budget

Each rule has a budget of 1,000 bytes. Its size is the sum of the UTF-8 bytes of:

| Part | Bytes |
| --- | --- |
| A fixed overhead per rule | 25 for rules 1-9, 26 for rules 10-99, 27 for rule 100 |
| Each origin | Its length |
| Each method | `GET` 6, `HEAD` 7, `PUT` 6, `POST` 7, `DELETE` 9 |
| Each allowed header and each exposed header | Its length |
| `MaxAgeSeconds` | 0 |

A rule of up to 1,000 bytes is accepted; one byte more is not. A rule that grows past the budget
splits neatly into two rules with the same methods and half the origins each.

## Setting rules in the dashboard

A bucket's CORS rules live with its settings in the [dashboard](https://app.strg.com) - the
**Settings** tab of the bucket's page, or the settings column beside the objects on a wide screen.
A change goes like this:

1. On the **CORS rules** card, the **Edit** button opens the **Edit CORS rules** dialog on the rules
   the bucket holds now.
2. The JSON can be edited in place, a whole configuration pasted in, or a predefined rule added -
   [Presets](https://docs.strg.com/cors-rules#presets) describes them. The editor marks every problem in place, naming the rule and
   field it concerns, before anything is saved.
3. **Save rules** stores the result.

Saving replaces every rule on the bucket, and saving an empty list, `[]`, removes them all. When a
rule lets every origin (`*`) use `PUT`, `POST` or `DELETE`, the dialog points it out: that is
allowed, but rarely what is meant, and naming your own site is usually the safer choice.

### Presets

**Add a predefined rule** builds one rule from a few answers, for the situations that come up most
often. Picking a preset, filling in what it asks for and pressing **Add rule** appends the rule after
your other rules; nothing is saved until **Save rules**. On a bucket whose *Bucket Protection* is
off, **Allow reads from any website** is already selected when the dialog opens.

| Preset | Asks for | Builds |
| --- | --- | --- |
| **Allow reads from any website** | Nothing | `AllowedOrigins` `*`; `GET`, `HEAD`; allowed header `range`; exposes `etag`, `content-length`, `content-range`, `accept-ranges`; 3,600 seconds |
| **Allow reads from my website** | Your website's origin | The same, for your origins only |
| **Browser uploads from my website** | Your website's origin | Your origins; `GET`, `HEAD`, `PUT`; any header; exposes `etag`; 3,600 seconds |
| **Full access for a web app** | Your website's origin | Your origins; `GET`, `HEAD`, `PUT`, `POST`, `DELETE`; any header; exposes `etag`, `x-amz-version-id`, `x-amz-request-id`; 3,600 seconds |
| **Local development** | Development server ports - `3000, 5173` to start with | `http://localhost:<port>` for each port; `GET`, `HEAD`, `PUT`, `POST`; any header; exposes `etag`; 600 seconds |

Where a preset asks for your website's origin, it wants the scheme, host and - if it is not the
default - the port, with no path. Several origins can be separated with commas, and
`https://*.example.com` covers every subdomain.

## Setting rules with the S3 API

Any S3 tool can manage the same rules: [`PutBucketCors`](https://docs.strg.com/s3-api/PutBucketCors) sets a
bucket's rules, [`GetBucketCors`](https://docs.strg.com/s3-api/GetBucketCors) reads them and
[`DeleteBucketCors`](https://docs.strg.com/s3-api/DeleteBucketCors) removes them. The access key making the call
needs:

| Operation | Access key needs |
| --- | --- |
| `GetBucketCors` | Nothing beyond what every key holds (*Read bucket settings*) |
| `PutBucketCors`, `DeleteBucketCors` | *Manage buckets*, on a key for all buckets. A key scoped to some buckets gets `403 AccessDenied`. [Access keys](https://docs.strg.com/access-keys) has the details |

The AWS CLI and SDKs wrap the list of rules in a `CORSRules` field. This file sets one rule:

```json title="cors.json"
{
  "CORSRules": [
    {
      "AllowedOrigins": ["https://www.example.com"],
      "AllowedMethods": ["GET", "HEAD", "PUT"],
      "AllowedHeaders": ["*"],
      "ExposeHeaders": ["etag"],
      "MaxAgeSeconds": 3600
    }
  ]
}
```

**AWS CLI**

```bash
aws s3api put-bucket-cors \
  --bucket my-bucket \
  --cors-configuration file://cors.json \
  --endpoint-url https://s3.eu-central-1.strg.com \
  --region eu-central-1

aws s3api get-bucket-cors \
  --bucket my-bucket \
  --endpoint-url https://s3.eu-central-1.strg.com \
  --region eu-central-1
```

**Python (boto3)**

```python
import boto3

s3 = boto3.client(
    "s3",
    endpoint_url="https://s3.eu-central-1.strg.com",
    region_name="eu-central-1",
)

s3.put_bucket_cors(
    Bucket="my-bucket",
    CORSConfiguration={
        "CORSRules": [
            {
                "AllowedOrigins": ["https://www.example.com"],
                "AllowedMethods": ["GET", "HEAD", "PUT"],
                "AllowedHeaders": ["*"],
                "ExposeHeaders": ["etag"],
                "MaxAgeSeconds": 3600,
            }
        ]
    },
)

print(s3.get_bucket_cors(Bucket="my-bucket")["CORSRules"])
```

**JavaScript (AWS SDK v3)**

```js title="put-cors.mjs"
import { S3Client, PutBucketCorsCommand, GetBucketCorsCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  endpoint: "https://s3.eu-central-1.strg.com",
  region: "eu-central-1",
});

await s3.send(
  new PutBucketCorsCommand({
    Bucket: "my-bucket",
    CORSConfiguration: {
      CORSRules: [
        {
          AllowedOrigins: ["https://www.example.com"],
          AllowedMethods: ["GET", "HEAD", "PUT"],
          AllowedHeaders: ["*"],
          ExposeHeaders: ["etag"],
          MaxAgeSeconds: 3600,
        },
      ],
    },
  }),
);

const { CORSRules } = await s3.send(new GetBucketCorsCommand({ Bucket: "my-bucket" }));
console.log(CORSRules);
```

`PutBucketCors` replaces every rule on the bucket, and STRG checks it against the same rules as
the dashboard before anything is stored. Over the S3 API, each situation has its own answer:

| Situation | Answer |
| --- | --- |
| A rule breaks a limit above | `400 InvalidRequest`, naming the rule and field |
| A rule carries an element STRG has no field for, or a method other than the five above | `400 InvalidRequest` |
| No rules at all | `400 MalformedXML` - `DeleteBucketCors` is the way to remove every rule |
| Neither `Content-MD5` nor an `x-amz-checksum-*` header | `400 InvalidRequest`. The AWS CLI and SDKs send one by default |
| A key scoped to some buckets | `403 AccessDenied` |
| `GetBucketCors` on a bucket without rules | `404 NoSuchCORSConfiguration` |

## Examples

Each example is a complete configuration, ready to paste into the dashboard editor. For the CLI or
an SDK, the list goes inside `{ "CORSRules": … }`.

### A single-page app uploading with presigned URLs

Your server hands out presigned `PUT` URLs, and the page on `https://app.example.com` uploads
straight to the bucket and reads the files back. `etag` is exposed because multipart uploaders need
it to complete an upload; an app that uses multipart uploads also lists `POST`.

```json
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["GET", "HEAD", "PUT"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["etag"],
    "MaxAgeSeconds": 3600
  }
]
```

### Public reads from any origin

Any web page may fetch objects with JavaScript - images drawn on a canvas, web fonts, JSON, and
media players that request byte ranges. On a bucket whose *Bucket Protection* is off, the page
reads objects by their plain URL, such as
`https://s3.eu-central-1.strg.com/my-bucket/photos/2024/cat.jpg`, with no signature; listing the
bucket still needs one. On a protected bucket, every request needs a presigned URL.

```json
[
  {
    "AllowedOrigins": ["*"],
    "AllowedMethods": ["GET", "HEAD"],
    "AllowedHeaders": ["range"],
    "ExposeHeaders": ["etag", "content-length", "content-range", "accept-ranges"],
    "MaxAgeSeconds": 3600
  }
]
```

### Production and local development side by side

Your site and its subdomains may read and upload, and a development server on your own machine may
do the same, with a shorter cache so changes show up sooner.

```json
[
  {
    "AllowedOrigins": ["https://example.com", "https://*.example.com"],
    "AllowedMethods": ["GET", "HEAD", "PUT", "POST"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["etag"],
    "MaxAgeSeconds": 3600
  },
  {
    "AllowedOrigins": ["http://localhost:3000", "http://localhost:5173"],
    "AllowedMethods": ["GET", "HEAD", "PUT", "POST"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["etag"],
    "MaxAgeSeconds": 600
  }
]
```
