# PutBucketCors

PutBucketCors sets a bucket's CORS configuration, replacing every rule it had with the set in the
request. Changing one rule therefore means reading the current set with
[GetBucketCors](https://docs.strg.com/s3-api/GetBucketCors), editing it and sending the whole set back; removing every
rule is the work of [DeleteBucketCors](https://docs.strg.com/s3-api/DeleteBucketCors). The dashboard and this operation
edit the same rules. [CORS rules](https://docs.strg.com/cors-rules) explains the rule format, the limits and how
rules are stored.

**Info: Good to know on STRG**

- Changing CORS rules is the work of an access key for all buckets holding **Manage buckets**.
- STRG checks each rule as it is saved, so a typo shows up right away rather than as a
  puzzling browser error later. A bucket holds up to 100 rules, `MaxAgeSeconds` goes up to 86,400,
  and each rule has a budget of 1,000 bytes.
- Rules are stored in a tidy, consistent form: `ID` is set aside, origins and header names are
  lowercased, and methods may come back in a different order.
- On a public bucket, anonymous readers can still get answers carrying the old CORS headers for up
  to 5 minutes after the change.
- `x-amz-expected-bucket-owner` is welcome and has nothing to check.

## When you'd use it

- A web app lets people upload a profile photo straight from the browser, with a presigned URL.
  The browser only sends that upload if the bucket's CORS rules allow the app's origin, so the
  app's deploy script sets them with PutBucketCors.
- A team adds a staging site next to production. Its deploy script reads the current rules, adds
  the staging origin and writes the full set back, so both sites keep working.
- A team keeps its bucket configuration in version control, and a release job writes the rules
  from the repository to the bucket, so what the repository says is what the bucket does.
- An agent building a small static site for someone sets the rules its pages need to fetch images
  and data from the bucket.

## Request

```http
PUT /?cors HTTP/1.1
Host: my-bucket.s3.eu-central-1.strg.com
Authorization: AWS4-HMAC-SHA256 Credential=...
x-amz-date: 20260926T120000Z
x-amz-content-sha256: <SHA-256 of the body, hex>
Content-MD5: <MD5 of the body, base64>
Content-Type: application/xml

<?xml version="1.0" encoding="UTF-8"?>
<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
  <CORSRule>
    <AllowedOrigin>https://www.example.com</AllowedOrigin>
    <AllowedMethod>GET</AllowedMethod>
    <AllowedMethod>HEAD</AllowedMethod>
    <AllowedMethod>PUT</AllowedMethod>
    <AllowedHeader>*</AllowedHeader>
    <ExposeHeader>ETag</ExposeHeader>
    <MaxAgeSeconds>3600</MaxAgeSeconds>
  </CORSRule>
</CORSConfiguration>
```

Path-style, the same request is `PUT /my-bucket?cors` on `s3.eu-central-1.strg.com`.
[Addressing](https://docs.strg.com/s3-api/overview#addressing) describes both styles.

### Permissions

The access key needs **Manage buckets** (`buckets:write`), a permission that belongs to keys for
all buckets. A key without it, or a key limited to certain buckets, gets `403 AccessDenied`.
[Access keys](https://docs.strg.com/access-keys) has the details.

### URI parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Bucket` | String | Yes | The bucket whose CORS configuration is set. |
| `cors` | Flag | Yes | Selects the CORS subresource. Sent without a value. |

STRG answers any other query parameter with `501 NotImplemented`, so a client knows straight
away that the request was not what it meant.

### Request headers

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `Content-MD5` | String | One integrity header is required | The base64 MD5 digest of the body. STRG checks it and answers `400 BadDigest` if it does not match. |
| `x-amz-checksum-crc32`, `x-amz-checksum-crc32c`, `x-amz-checksum-crc64nvme`, `x-amz-checksum-sha1`, `x-amz-checksum-sha256` | String | One integrity header is required | A checksum of the body, which works in place of `Content-MD5`. |
| `x-amz-sdk-checksum-algorithm` | String | No | The algorithm of the `x-amz-checksum-*` header the SDK sends. Accepted. |
| `x-amz-expected-bucket-owner` | String | No | Welcome, and has nothing to check: an access key only ever reaches its own account's buckets. |

Every request carries either `Content-MD5` or an `x-amz-checksum-*` header, and one with neither
gets `400 InvalidRequest`. The AWS CLI and SDKs add one automatically.

The body arrives whole. STRG answers a streaming `aws-chunked` upload of the body with
`501 NotImplemented`; the AWS CLI and SDKs never send one for this operation.

### Request body

A `CORSConfiguration` document of at most 2 MiB, holding 1 to 100 `CORSRule` elements. The
document is plain XML: a comment, CDATA section, `DOCTYPE`, processing instruction or namespace
prefix gets `400 MalformedXML`.

| Element | Type | Required | Description |
| --- | --- | --- | --- |
| `CORSConfiguration` | Container | Yes | The root element. |
| `CORSRule` | Container | Yes | One rule, repeated for each. 1 to 100. |
| `AllowedOrigin` | String | Yes, at least one per rule | An origin the rule allows. `*` for every origin, alone in its rule; `https` for every HTTPS origin; or `http://` or `https://` plus a host and an optional port, with no path and no trailing slash. The host may be `*`, or start with one `*.` label, as in `https://*.example.com`. Stored lowercased. |
| `AllowedMethod` | String | Yes, at least one per rule | `GET`, `HEAD`, `PUT`, `POST` or `DELETE`, in capitals. Any other method gets `400 InvalidRequest`. |
| `AllowedHeader` | String | No | A request header the rule allows. `*` allows any and is then the only one; a trailing `*` matches a prefix, as in `x-amz-*`. Stored lowercased. |
| `ExposeHeader` | String | No | A response header browsers may expose to scripts. Named in full, without `*`. Stored lowercased. |
| `MaxAgeSeconds` | Integer | No | How long a browser may cache the preflight answer, in seconds. 0 to 86,400. |
| `ID` | String | No | Accepted and set aside, so it is never returned. Configurations copied from elsewhere work as they are. |

Each origin, method and header appears once per rule; origins and headers compare
case-insensitively, so `https://Example.com` and `https://example.com` count as the same. Each
rule has a budget of 1,000 bytes, made up of a fixed overhead per rule and the length of every
origin, method and header - [the rule size budget](https://docs.strg.com/cors-rules#the-rule-size-budget) shows
how it is counted. An element not in this table gets `400 InvalidRequest`, so a misspelt name
shows up straight away instead of being quietly dropped.

## Response

A successful request answers `200 OK` with no body. The new rules replace the old ones.

The rules apply on the S3 endpoint:

- A browser preflight (`OPTIONS`) is answered from them. A preflight no rule allows - or for a
  bucket that does not exist - gets `403 AccessForbidden`.
- Responses to an allowed origin carry the matching `Access-Control-*` headers.
- On a bucket with *Bucket Protection* off, an anonymous read may be answered from STRG's
  cache, with the CORS headers of the rules in force when it was cached, for **up to 5 minutes**
  after the change. STRG keeps no anonymous answer longer than that, and caches one only when
  the object's own `Cache-Control` allows a shared cache to keep it.
  [Anonymous reads](https://docs.strg.com/s3-api/overview#anonymous-reads) has the details.
- The STRG dashboard reaches your buckets whatever the rules say, so a rule set that leaves
  out the dashboard never locks you out of your own bucket.

### Response headers

| Name | Description |
| --- | --- |
| `x-amz-request-id` | The request ID, for support requests. |

### Response body

None.

## Errors

These are the errors this operation can answer, and the situation behind each.

| Code | HTTP status | When |
| --- | --- | --- |
| `InvalidRequest` | 400 | A rule goes past one of the limits above - its message names the rule and the field - or the document contains an element or method STRG does not take, or the request carries neither `Content-MD5` nor an `x-amz-checksum-*` header. |
| `MalformedXML` | 400 | The body is not well-formed XML, its root is not `CORSConfiguration`, it contains no `CORSRule`, or it uses a comment, CDATA section, `DOCTYPE`, processing instruction or namespace prefix. |
| `BadDigest` | 400 | `Content-MD5` does not match the body. |
| `XAmzContentSHA256Mismatch` | 400 | `x-amz-content-sha256` does not match the body. |
| `MaxMessageLengthExceeded` | 400 | The body is larger than 2 MiB. |
| `NoSuchBucket` | 404 | The bucket does not exist, it is in a region other than the endpoint's, or your account has no storage in this endpoint's region yet. |
| `AccessDenied` | 403 | The access key lacks **Manage buckets**, is limited to certain buckets, or your account is not active. |
| `InvalidAccessKeyId` | 403 | The access key does not exist, has expired or been revoked, or was created so recently that it is not yet usable in this endpoint's region - a moment later it is. |
| `SignatureDoesNotMatch` | 403 | The request signature is wrong. |
| `RequestTimeTooSkewed` | 403 | The request time is more than 15 minutes from the server's. |
| `AuthorizationHeaderMalformed` | 400 | The signature's credential scope names a region other than the endpoint's. |
| `NotImplemented` | 501 | The request carries a query parameter this operation does not take, or sends the body as a streaming `aws-chunked` upload. |
| `SlowDown` | 503 | Too many requests at once; a retry after the `Retry-After` interval goes through. |
| `ServiceUnavailable` | 503 | A temporary failure; a retry usually succeeds. |

## Differences from Amazon S3

A few things work a little differently on STRG, each listed here with what it means for your
requests.

| Aspect | Amazon S3 | STRG |
| --- | --- | --- |
| Permission | `s3:PutBucketCORS`, granted per identity | **Manage buckets**, on an access key for all buckets |
| Anonymous reads after a change | Not applicable | On a public bucket, cached anonymous answers can carry the old CORS headers for up to 5 minutes |
| Number of rules | Limited by a 64 KB document | Up to 100 rules |
| Size of a rule | None beyond the 64 KB document | Up to 1,000 bytes per rule, counted as [the rule size budget](https://docs.strg.com/cors-rules#the-rule-size-budget) describes |
| `MaxAgeSeconds` | Any non-negative integer | 0 to 86,400 |
| `AllowedOrigin` | Any string with at most one `*` | `*`, `https`, or `http(s)://host[:port]` with `*` as the whole host or its first label |
| `AllowedMethod` | `GET`, `PUT`, `HEAD`, `POST`, `DELETE` | The same five |
| Rule `ID` | Stored and returned | Set aside when the rules are saved |
| Case of origins and headers | Stored as sent | Lowercased |
| Order of `AllowedMethod` | Stored as sent | Not guaranteed, so comparing methods as a set is the reliable check |
| Bucket in another region | `301` redirect with `x-amz-bucket-region` | `404 NoSuchBucket`. Each bucket answers on its own regional endpoint. |
| `x-amz-expected-bucket-owner` | Checked against the bucket owner | Welcome, with nothing to check: a key only ever reaches buckets of its own account |

## Examples

Each example lets pages on `https://www.example.com` read and upload objects in `my-bucket`. The
CLI reads the rules from a file in the same JSON form the dashboard edits, wrapped in `CORSRules`:

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

The command prints nothing on success.

**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,
            }
        ]
    },
)
```

**JavaScript (AWS SDK v3)**

```javascript
import { PutBucketCorsCommand, S3Client } from "@aws-sdk/client-s3";

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

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

Reading the rules back with [GetBucketCors](https://docs.strg.com/s3-api/GetBucketCors) returns `etag` rather than
`ETag`, because header names are stored lowercased.

When a rule goes past a limit, nothing is saved and the bucket keeps its previous rules. The error
message names the rule and the field - here, for a `MaxAgeSeconds` of `90000` in the first rule:

```text
An error occurred (InvalidRequest) when calling the PutBucketCors operation: Invalid CORS configuration - Rule 1, MaxAgeSeconds: at most 86400 seconds (one day).
```

## Related

- [GetBucketCors](https://docs.strg.com/s3-api/GetBucketCors)
- [DeleteBucketCors](https://docs.strg.com/s3-api/DeleteBucketCors)
- [CORS rules](https://docs.strg.com/cors-rules)
- [Access keys](https://docs.strg.com/access-keys)
- [S3 API overview](https://docs.strg.com/s3-api/overview)
