335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""S3 CORS enforcement integration tests."""
|
|
|
|
import logging
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
import pytest
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class TestS3CORS:
|
|
"""Test S3 CORS enforcement."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_bucket(self, s3_client, unique_name, endpoint_url):
|
|
"""Set up a test bucket with an object for CORS testing."""
|
|
self.bucket = f"pytest-cors-{unique_name}"
|
|
self.endpoint = endpoint_url
|
|
self.s3 = s3_client
|
|
|
|
s3_client.create_bucket(Bucket=self.bucket)
|
|
s3_client.put_object(
|
|
Bucket=self.bucket,
|
|
Key="cors-test.txt",
|
|
Body=b"hello cors",
|
|
ContentType="text/plain",
|
|
)
|
|
|
|
yield
|
|
|
|
# Cleanup
|
|
try:
|
|
s3_client.delete_bucket_cors(Bucket=self.bucket)
|
|
except Exception as e:
|
|
logger.warning("Failed to clean up CORS config for bucket %s: %s", self.bucket, e)
|
|
try:
|
|
s3_client.delete_object(Bucket=self.bucket, Key="cors-test.txt")
|
|
s3_client.delete_bucket(Bucket=self.bucket)
|
|
except Exception as e:
|
|
logger.warning("Failed to clean up S3 bucket %s: %s", self.bucket, e)
|
|
|
|
def _raw_request(self, method, path, headers=None):
|
|
"""Make a raw HTTP request; returns (status_code, lowercase_headers_dict)."""
|
|
url = f"{self.endpoint}/{self.bucket}{path}"
|
|
req = urllib.request.Request(url, method=method)
|
|
if headers:
|
|
for k, v in headers.items():
|
|
req.add_header(k, v)
|
|
try:
|
|
with urllib.request.urlopen(req) as resp:
|
|
return resp.status, {k.lower(): v for k, v in resp.headers.items()}
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, {k.lower(): v for k, v in e.headers.items()}
|
|
|
|
def test_preflight_without_cors_config_returns_403(self):
|
|
"""Test preflight request without CORS config returns 403."""
|
|
status, _ = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{"Origin": "http://localhost:3000", "Access-Control-Request-Method": "GET"},
|
|
)
|
|
assert status == 403
|
|
|
|
def test_put_bucket_cors_wildcard(self):
|
|
"""Test PutBucketCors with wildcard origin."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["*"],
|
|
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
|
|
"AllowedHeaders": ["*"],
|
|
"ExposeHeaders": ["ETag"],
|
|
"MaxAgeSeconds": 3000,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
def test_wildcard_preflight_returns_200(self):
|
|
"""Test wildcard CORS preflight returns 200."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["*"],
|
|
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
|
|
"AllowedHeaders": ["*"],
|
|
"ExposeHeaders": ["ETag"],
|
|
"MaxAgeSeconds": 3000,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
status, headers = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{"Origin": "http://localhost:3000", "Access-Control-Request-Method": "GET"},
|
|
)
|
|
assert status == 200
|
|
assert headers.get("access-control-allow-origin") == "*"
|
|
assert headers.get("access-control-max-age") == "3000"
|
|
assert "GET" in headers.get("access-control-allow-methods", "").upper()
|
|
|
|
def test_wildcard_actual_get_returns_cors_headers(self):
|
|
"""Test actual GET with Origin header returns CORS headers."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["*"],
|
|
"AllowedMethods": ["GET"],
|
|
"AllowedHeaders": ["*"],
|
|
"ExposeHeaders": ["ETag"],
|
|
"MaxAgeSeconds": 3000,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
status, headers = self._raw_request(
|
|
"GET", "/cors-test.txt", {"Origin": "http://localhost:3000"}
|
|
)
|
|
assert headers.get("access-control-allow-origin") == "*"
|
|
vary = headers.get("vary", "")
|
|
assert any(t.strip().lower() == "origin" for t in vary.split(","))
|
|
assert "ETag" in headers.get("access-control-expose-headers", "")
|
|
|
|
def test_get_without_origin_has_no_cors_headers(self):
|
|
"""Test GET without Origin header has no CORS headers."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["*"],
|
|
"AllowedMethods": ["GET"],
|
|
"AllowedHeaders": ["*"],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
_, headers = self._raw_request("GET", "/cors-test.txt")
|
|
assert "access-control-allow-origin" not in headers
|
|
|
|
def test_options_without_origin_has_no_cors_headers(self):
|
|
"""Test OPTIONS without Origin header has no CORS headers."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["*"],
|
|
"AllowedMethods": ["GET"],
|
|
"AllowedHeaders": ["*"],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
_, headers = self._raw_request("OPTIONS", "/cors-test.txt")
|
|
assert "access-control-allow-origin" not in headers
|
|
|
|
def test_specific_origin_echoes_origin(self):
|
|
"""Test specific origin CORS config echoes the origin."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["https://example.com"],
|
|
"AllowedMethods": ["GET", "PUT"],
|
|
"AllowedHeaders": ["Content-Type", "Authorization"],
|
|
"ExposeHeaders": ["ETag", "x-amz-request-id"],
|
|
"MaxAgeSeconds": 600,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
status, headers = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{
|
|
"Origin": "https://example.com",
|
|
"Access-Control-Request-Method": "GET",
|
|
"Access-Control-Request-Headers": "Content-Type",
|
|
},
|
|
)
|
|
assert status == 200
|
|
assert headers.get("access-control-allow-origin") == "https://example.com"
|
|
assert headers.get("access-control-max-age") == "600"
|
|
|
|
def test_non_matching_origin_returns_403(self):
|
|
"""Test non-matching origin returns 403."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["https://example.com"],
|
|
"AllowedMethods": ["GET"],
|
|
"AllowedHeaders": ["*"],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
status, _ = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{"Origin": "https://attacker.evil.com", "Access-Control-Request-Method": "GET"},
|
|
)
|
|
assert status == 403
|
|
|
|
def test_non_matching_method_returns_403(self):
|
|
"""Test non-matching method returns 403."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["https://example.com"],
|
|
"AllowedMethods": ["GET", "PUT"],
|
|
"AllowedHeaders": ["*"],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
status, _ = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{"Origin": "https://example.com", "Access-Control-Request-Method": "DELETE"},
|
|
)
|
|
assert status == 403
|
|
|
|
def test_delete_bucket_cors(self):
|
|
"""Test DeleteBucketCors restores 403 on preflight."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["*"],
|
|
"AllowedMethods": ["GET"],
|
|
"AllowedHeaders": ["*"],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
self.s3.delete_bucket_cors(Bucket=self.bucket)
|
|
|
|
status, _ = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{"Origin": "http://localhost:3000", "Access-Control-Request-Method": "GET"},
|
|
)
|
|
assert status == 403
|
|
|
|
def test_subdomain_wildcard_matches(self):
|
|
"""Test subdomain wildcard pattern matches subdomains."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["http://*.example.com"],
|
|
"AllowedMethods": ["GET"],
|
|
"AllowedHeaders": ["*"],
|
|
"MaxAgeSeconds": 120,
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
status, headers = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{"Origin": "http://app.example.com", "Access-Control-Request-Method": "GET"},
|
|
)
|
|
assert status == 200
|
|
assert headers.get("access-control-allow-origin") == "http://app.example.com"
|
|
|
|
def test_subdomain_wildcard_rejects_wrong_scheme(self):
|
|
"""Test subdomain wildcard rejects different scheme."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["http://*.example.com"],
|
|
"AllowedMethods": ["GET"],
|
|
"AllowedHeaders": ["*"],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
status, _ = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{"Origin": "https://app.example.com", "Access-Control-Request-Method": "GET"},
|
|
)
|
|
assert status == 403
|
|
|
|
def test_subdomain_wildcard_rejects_different_domain(self):
|
|
"""Test subdomain wildcard rejects different domain."""
|
|
self.s3.put_bucket_cors(
|
|
Bucket=self.bucket,
|
|
CORSConfiguration={
|
|
"CORSRules": [
|
|
{
|
|
"AllowedOrigins": ["http://*.example.com"],
|
|
"AllowedMethods": ["GET"],
|
|
"AllowedHeaders": ["*"],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
status, _ = self._raw_request(
|
|
"OPTIONS",
|
|
"/cors-test.txt",
|
|
{"Origin": "http://app.other.com", "Access-Control-Request-Method": "GET"},
|
|
)
|
|
assert status == 403
|