second first commit
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user