This commit is contained in:
Iliyan Angelov
2025-09-19 11:58:53 +03:00
parent 306b20e24a
commit 6b247e5b9f
11423 changed files with 1500615 additions and 778 deletions

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from django.apps import AppConfig
from django.core.checks import Tags, register
from corsheaders.checks import check_settings
class CorsHeadersAppConfig(AppConfig):
name = "corsheaders"
verbose_name = "django-cors-headers"
def ready(self) -> None:
register(Tags.security)(check_settings)

View File

@@ -0,0 +1,171 @@
from __future__ import annotations
import re
from collections.abc import Sequence
from typing import Any
from urllib.parse import urlsplit
from django.conf import settings
from django.core.checks import CheckMessage, Error
from corsheaders.conf import conf
re_type = type(re.compile(""))
def check_settings(**kwargs: Any) -> list[CheckMessage]:
errors: list[CheckMessage] = []
if not is_sequence(conf.CORS_ALLOW_HEADERS, str):
errors.append(
Error(
"CORS_ALLOW_HEADERS should be a sequence of strings.",
id="corsheaders.E001",
)
)
if not is_sequence(conf.CORS_ALLOW_METHODS, str):
errors.append(
Error(
"CORS_ALLOW_METHODS should be a sequence of strings.",
id="corsheaders.E002",
)
)
if not isinstance(conf.CORS_ALLOW_CREDENTIALS, bool):
errors.append( # type: ignore [unreachable]
Error("CORS_ALLOW_CREDENTIALS should be a bool.", id="corsheaders.E003")
)
if not isinstance(conf.CORS_ALLOW_PRIVATE_NETWORK, bool):
errors.append( # type: ignore [unreachable]
Error(
"CORS_ALLOW_PRIVATE_NETWORK should be a bool.",
id="corsheaders.E015",
)
)
if (
not isinstance(conf.CORS_PREFLIGHT_MAX_AGE, int) # type: ignore [redundant-expr]
or conf.CORS_PREFLIGHT_MAX_AGE < 0
):
errors.append(
Error(
(
"CORS_PREFLIGHT_MAX_AGE should be an integer greater than "
+ "or equal to zero."
),
id="corsheaders.E004",
)
)
if not isinstance(conf.CORS_ALLOW_ALL_ORIGINS, bool):
if hasattr(settings, "CORS_ALLOW_ALL_ORIGINS"): # type: ignore [unreachable]
allow_all_alias = "CORS_ALLOW_ALL_ORIGINS"
else:
allow_all_alias = "CORS_ORIGIN_ALLOW_ALL"
errors.append(
Error(
f"{allow_all_alias} should be a bool.",
id="corsheaders.E005",
)
)
if hasattr(settings, "CORS_ALLOWED_ORIGINS"):
allowed_origins_alias = "CORS_ALLOWED_ORIGINS"
else:
allowed_origins_alias = "CORS_ORIGIN_WHITELIST"
if not is_sequence(conf.CORS_ALLOWED_ORIGINS, str):
errors.append(
Error(
f"{allowed_origins_alias} should be a sequence of strings.",
id="corsheaders.E006",
)
)
else:
special_origin_values = (
# From 'security sensitive' contexts
"null",
# From files on Chrome on Android
# https://bugs.chromium.org/p/chromium/issues/detail?id=991107
"file://",
)
for origin in conf.CORS_ALLOWED_ORIGINS:
if origin in special_origin_values:
continue
parsed = urlsplit(origin)
if parsed.scheme == "" or parsed.netloc == "":
errors.append(
Error(
f"Origin {repr(origin)} in {allowed_origins_alias} is missing scheme or netloc",
id="corsheaders.E013",
hint=(
"Add a scheme (e.g. https://) or netloc (e.g. "
+ "example.com)."
),
)
)
else:
# Only do this check in this case because if the scheme is not
# provided, netloc ends up in path
for part in ("path", "query", "fragment"):
if getattr(parsed, part) != "":
errors.append(
Error(
f"Origin {repr(origin)} in {allowed_origins_alias} should not have {part}",
id="corsheaders.E014",
)
)
if hasattr(settings, "CORS_ALLOWED_ORIGIN_REGEXES"):
allowed_regexes_alias = "CORS_ALLOWED_ORIGIN_REGEXES"
else:
allowed_regexes_alias = "CORS_ORIGIN_REGEX_WHITELIST"
if not is_sequence(conf.CORS_ALLOWED_ORIGIN_REGEXES, (str, re_type)):
errors.append(
Error(
f"{allowed_regexes_alias} should be a sequence of strings and/or compiled regexes.",
id="corsheaders.E007",
)
)
if not is_sequence(conf.CORS_EXPOSE_HEADERS, str):
errors.append(
Error("CORS_EXPOSE_HEADERS should be a sequence.", id="corsheaders.E008")
)
if not isinstance(conf.CORS_URLS_REGEX, (str, re_type)):
errors.append(
Error("CORS_URLS_REGEX should be a string or regex.", id="corsheaders.E009")
)
if hasattr(settings, "CORS_MODEL"):
errors.append(
Error(
(
"The CORS_MODEL setting has been removed - see "
+ "django-cors-headers' HISTORY."
),
id="corsheaders.E012",
)
)
if hasattr(settings, "CORS_REPLACE_HTTPS_REFERER"):
errors.append(
Error(
(
"The CORS_REPLACE_HTTPS_REFERER setting has been removed"
+ " - see django-cors-headers' CHANGELOG."
),
id="corsheaders.E013",
)
)
return errors
def is_sequence(thing: Any, type_or_types: type[Any] | tuple[type[Any], ...]) -> bool:
return isinstance(thing, Sequence) and all(
isinstance(x, type_or_types) for x in thing
)

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from collections.abc import Sequence
from re import Pattern
from typing import Union, cast
from django.conf import settings
from corsheaders.defaults import default_headers, default_methods
class Settings:
"""
Shadow Django's settings with a little logic
"""
@property
def CORS_ALLOW_HEADERS(self) -> Sequence[str]:
return getattr(settings, "CORS_ALLOW_HEADERS", default_headers)
@property
def CORS_ALLOW_METHODS(self) -> Sequence[str]:
return getattr(settings, "CORS_ALLOW_METHODS", default_methods)
@property
def CORS_ALLOW_CREDENTIALS(self) -> bool:
return getattr(settings, "CORS_ALLOW_CREDENTIALS", False)
@property
def CORS_ALLOW_PRIVATE_NETWORK(self) -> bool:
return getattr(settings, "CORS_ALLOW_PRIVATE_NETWORK", False)
@property
def CORS_PREFLIGHT_MAX_AGE(self) -> int:
return getattr(settings, "CORS_PREFLIGHT_MAX_AGE", 86400)
@property
def CORS_ALLOW_ALL_ORIGINS(self) -> bool:
return getattr(
settings,
"CORS_ALLOW_ALL_ORIGINS",
getattr(settings, "CORS_ORIGIN_ALLOW_ALL", False),
)
@property
def CORS_ALLOWED_ORIGINS(self) -> list[str] | tuple[str]:
value = getattr(
settings,
"CORS_ALLOWED_ORIGINS",
getattr(settings, "CORS_ORIGIN_WHITELIST", ()),
)
return cast(Union[list[str], tuple[str]], value)
@property
def CORS_ALLOWED_ORIGIN_REGEXES(self) -> Sequence[str | Pattern[str]]:
return getattr(
settings,
"CORS_ALLOWED_ORIGIN_REGEXES",
getattr(settings, "CORS_ORIGIN_REGEX_WHITELIST", ()),
)
@property
def CORS_EXPOSE_HEADERS(self) -> Sequence[str]:
return getattr(settings, "CORS_EXPOSE_HEADERS", ())
@property
def CORS_URLS_REGEX(self) -> str | Pattern[str]:
return getattr(settings, "CORS_URLS_REGEX", r"^.*$")
conf = Settings()

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
# Kept here for backwards compatibility
default_headers = (
"accept",
"authorization",
"content-type",
"user-agent",
"x-csrftoken",
"x-requested-with",
)
default_methods = (
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
)

View File

@@ -0,0 +1,166 @@
from __future__ import annotations
import re
from collections.abc import Awaitable
from typing import Callable
from urllib.parse import SplitResult, urlsplit
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
from django.http import HttpRequest, HttpResponse
from django.http.response import HttpResponseBase
from django.utils.cache import patch_vary_headers
from corsheaders.conf import conf
from corsheaders.signals import check_request_enabled
ACCESS_CONTROL_ALLOW_ORIGIN = "access-control-allow-origin"
ACCESS_CONTROL_EXPOSE_HEADERS = "access-control-expose-headers"
ACCESS_CONTROL_ALLOW_CREDENTIALS = "access-control-allow-credentials"
ACCESS_CONTROL_ALLOW_HEADERS = "access-control-allow-headers"
ACCESS_CONTROL_ALLOW_METHODS = "access-control-allow-methods"
ACCESS_CONTROL_MAX_AGE = "access-control-max-age"
ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK = "access-control-request-private-network"
ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK = "access-control-allow-private-network"
class CorsMiddleware:
sync_capable = True
async_capable = True
def __init__(
self,
get_response: (
Callable[[HttpRequest], HttpResponseBase]
| Callable[[HttpRequest], Awaitable[HttpResponseBase]]
),
) -> None:
self.get_response = get_response
self.async_mode = iscoroutinefunction(self.get_response)
if self.async_mode:
# Mark the class as async-capable, but do the actual switch
# inside __call__ to avoid swapping out dunder methods
markcoroutinefunction(self)
def __call__(
self, request: HttpRequest
) -> HttpResponseBase | Awaitable[HttpResponseBase]:
if self.async_mode:
return self.__acall__(request)
response: HttpResponseBase | None = self.check_preflight(request)
if response is None:
result = self.get_response(request)
assert isinstance(result, HttpResponseBase)
response = result
self.add_response_headers(request, response)
return response
async def __acall__(self, request: HttpRequest) -> HttpResponseBase:
response = self.check_preflight(request)
if response is None:
result = self.get_response(request)
assert not isinstance(result, HttpResponseBase)
response = await result
self.add_response_headers(request, response)
return response
def check_preflight(self, request: HttpRequest) -> HttpResponseBase | None:
"""
Generate a response for CORS preflight requests.
"""
request._cors_enabled = self.is_enabled(request) # type: ignore [attr-defined]
if (
request._cors_enabled # type: ignore [attr-defined]
and request.method == "OPTIONS"
and "access-control-request-method" in request.headers
):
return HttpResponse(headers={"content-length": "0"})
return None
def add_response_headers(
self, request: HttpRequest, response: HttpResponseBase
) -> HttpResponseBase:
"""
Add the respective CORS headers
"""
enabled = getattr(request, "_cors_enabled", None)
if enabled is None:
enabled = self.is_enabled(request)
if not enabled:
return response
patch_vary_headers(response, ("origin",))
origin = request.headers.get("origin")
if not origin:
return response
try:
url = urlsplit(origin)
except ValueError:
return response
if (
not conf.CORS_ALLOW_ALL_ORIGINS
and not self.origin_found_in_white_lists(origin, url)
and not self.check_signal(request)
):
return response
if conf.CORS_ALLOW_ALL_ORIGINS and not conf.CORS_ALLOW_CREDENTIALS:
response[ACCESS_CONTROL_ALLOW_ORIGIN] = "*"
else:
response[ACCESS_CONTROL_ALLOW_ORIGIN] = origin
if conf.CORS_ALLOW_CREDENTIALS:
response[ACCESS_CONTROL_ALLOW_CREDENTIALS] = "true"
if len(conf.CORS_EXPOSE_HEADERS):
response[ACCESS_CONTROL_EXPOSE_HEADERS] = ", ".join(
conf.CORS_EXPOSE_HEADERS
)
if request.method == "OPTIONS":
response[ACCESS_CONTROL_ALLOW_HEADERS] = ", ".join(conf.CORS_ALLOW_HEADERS)
response[ACCESS_CONTROL_ALLOW_METHODS] = ", ".join(conf.CORS_ALLOW_METHODS)
if conf.CORS_PREFLIGHT_MAX_AGE:
response[ACCESS_CONTROL_MAX_AGE] = str(conf.CORS_PREFLIGHT_MAX_AGE)
if (
conf.CORS_ALLOW_PRIVATE_NETWORK
and request.headers.get(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK) == "true"
):
response[ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK] = "true"
return response
def origin_found_in_white_lists(self, origin: str, url: SplitResult) -> bool:
return (
(origin == "null" and origin in conf.CORS_ALLOWED_ORIGINS)
or self._url_in_whitelist(url)
or self.regex_domain_match(origin)
)
def regex_domain_match(self, origin: str) -> bool:
return any(
re.match(domain_pattern, origin)
for domain_pattern in conf.CORS_ALLOWED_ORIGIN_REGEXES
)
def is_enabled(self, request: HttpRequest) -> bool:
return bool(
re.match(conf.CORS_URLS_REGEX, request.path_info)
) or self.check_signal(request)
def check_signal(self, request: HttpRequest) -> bool:
signal_responses = check_request_enabled.send(sender=None, request=request)
return any(return_value for function, return_value in signal_responses)
def _url_in_whitelist(self, url: SplitResult) -> bool:
origins = [urlsplit(o) for o in conf.CORS_ALLOWED_ORIGINS]
return any(
origin.scheme == url.scheme and origin.netloc == url.netloc
for origin in origins
)

View File

@@ -0,0 +1,8 @@
from __future__ import annotations
from django.dispatch import Signal
# If any attached handler returns Truthy, CORS will be allowed for the request.
# This can be used to build custom logic into the request handling when the
# configuration doesn't work.
check_request_enabled = Signal()