CORS has a reputation as an obstacle — the thing that breaks your fetch call and
that you make go away with CORS_ALLOW_ALL_ORIGINS = True. That reputation comes
from meeting it on read endpoints, where the worst case of getting it wrong is
that someone else’s page can display your public data.
It is a completely different question on an endpoint that writes. And
embedding a collaborative form in a customer’s own website is exactly that: a
visitor on client-site.example loads a form served by us, joins a session —
which enrols them and consumes a slot — and saves answers into shared data.
This is the CORS layer for the collaborative embed in PaperPoint, and the reasoning behind each decision, because on a write surface every one of them is load-bearing.
Two APIs, two policies
"""CORS for the collaborative embed — strict, per-form, origin-allow-listed.
The read-only public results feed under ``/api/v1/public/`` reflects
``Access-Control-Allow-Origin: *`` (handled by django-cors-headers). We must
NOT do that for ``/api/v1/collab/*``: collaboration is a *write* surface —
joining enrolls a visitor and consumes a slot, saving mutates shared data — so
a cross-origin request is only honoured when the calling ``Origin`` is on the
target form's owner-managed allow-list.
"""
Same application, two prefixes, two policies — and the docstring states the distinction in terms of effect, not of HTTP verb.
That framing matters. “GET is safe, POST is not” is the usual shorthand and it is wrong in both directions: a GET that consumes a one-time token mutates state, and a POST to a search endpoint does not. The question is what the request does, and here it is spelled out: joining enrols a visitor and consumes a slot.
* on the public results feed is correct and deliberate: the data is already
public, and anyone can read it with curl regardless. Refusing browsers what you
grant to every HTTP client is theatre.
* on the collaborative API would mean any page on the internet can enrol its
visitors into a customer’s form session. Not steal data — create it, and
consume the customer’s capacity while doing so.
The allow-list belongs to the form, not to the deployment
This is the structural decision, and it is the one that differs most from how CORS is usually configured.
def _origin_allowed(self, access_code, origin):
from .models_collaborative import CollaborativeSession
session = (
CollaborativeSession.objects
.filter(access_code=access_code)
.select_related("form_template")
.first()
)
if not session or not session.form_template_id:
return False
return session.form_template.collab_embed_allows(origin)
The standard setup is CORS_ALLOWED_ORIGINS in settings.py: one list, one
deployment, applied to every endpoint. In a multi-customer product that is
immediately wrong. Customer A embeds their form on a-site.example; customer B
on b-site.example. A global list containing both means A’s site is authorised
against B’s forms, because the list has no idea which resource is being
requested.
So the allow-list lives on FormTemplate.collab_embed_origins, gated by
is_collab_embeddable, and it is managed by the form’s owner. The question
becomes “may this origin write to this form?” — which is the question that
was always being asked, and which a settings constant structurally cannot
represent.
The access code comes from the path:
_PATH_RE = re.compile(r"^/api/v1/collab/(?P<ac>[A-Z0-9]{6})/")
and the docstring explains what that forces on the client:
"""
The access code is taken from the request path (``/api/v1/collab/{AC}/…``),
which is why the embed SDK always uses the session-scoped routes — including
``/collab/{AC}/join/`` — never the bare ``/collab/join/`` (that one stays
same-origin for the mobile app and is never CORS-enabled).
"""
The middleware needs to know which resource is being addressed before the view runs, so the resource has to be identifiable from the URL alone. A body parameter would not do: preflight requests have no body, so the browser would ask “may I?” about a resource the server cannot name.
That is a real design constraint worth internalising: per-resource CORS requires the resource in the path. If your API identifies resources in the body, you cannot do per-resource CORS at all, and you will discover this after building the allow-list UI.
And the bare route stays same-origin, forever, for the mobile app. Two routes to the same operation with different exposure — which looks like duplication and is actually the boundary. One is browser-embeddable under an allow-list; the other is not embeddable at all, and cannot be, because no code path adds CORS headers to it.
Normalising an origin, and the two attacks it stops
def normalize_origin(raw):
if not raw or not isinstance(raw, str):
return None
raw = raw.strip()
if not raw:
return None
parts = urlsplit(raw)
scheme = (parts.scheme or "").lower()
host = (parts.hostname or "").lower()
if scheme not in ("http", "https") or not host:
return None
# Reject anything carrying a path/query/fragment or userinfo — a real
# Origin header never has them, so their presence means a malformed entry.
if parts.path not in ("", "/") or parts.query or parts.fragment or parts.username:
return None
port = None
try:
port = parts.port
except ValueError:
return None
if port is not None and str(port) != _DEFAULT_PORTS.get(scheme):
return f"{scheme}://{host}:{port}"
return f"{scheme}://{host}"
An origin is scheme://host[:port] and nothing else. Every rejection in that
function corresponds to something real.
parts.username is the important one. https://trusted.example@evil.example
looks, to a person skim-reading an allow-list entry, like trusted.example. It
is not: the host is evil.example and trusted.example is a username. This is
the oldest URL-confusion trick there is and it still lands, because the eye stops
at the first dot-separated thing that looks like a domain.
There are two layers of defence here, and both are deliberate.
urlsplit().hostname already returns evil.example, so normalisation alone would
produce the correct — and non-matching — value. The explicit rejection means the
entry is refused at the point where an administrator typed it, with a chance
to say why, instead of being silently stored as an origin that will never match
and that its author believes is working.
Path, query and fragment are rejected for the same reason. A browser never
sends Origin: https://site.example/app. Its presence means a human wrote a URL
where an origin was wanted — a category error that produces an entry that cannot
match anything. Better refused than mysteriously inert.
The default-port normalisation is a usability rule with a security edge.
https://site.example:443 and https://site.example are the same origin, but a
browser only ever sends the second. Without this line, an administrator who
helpfully types the port creates an entry that never matches, and then — this is
the failure that matters — starts loosening things until something works.
Most restrictions are made unforgiving on purpose:
"""
Matching is strict, exact equality after normalization: no wildcards, no
port-agnostic fallback. Collaboration is a write surface, so the allow-list is
deliberately unforgiving — ``http://localhost:3000`` and ``https://localhost:3000``
are two different entries, on purpose.
"""
No *.customer.example. A wildcard over a subdomain is a bet that every current
and future subdomain is trustworthy — including the one pointing at a
decommissioned SaaS whose CNAME someone forgot to remove, which is the standard
subdomain-takeover path into exactly this kind of allow-list.
And http and https being separate entries is right even though it is annoying
in development. They are different origins to the browser, and quietly treating a
plaintext origin as equivalent to its TLS counterpart is how a development
convenience reaches production.
No credentials, and that is the whole CSRF story
@staticmethod
def _apply_cors(response, origin, preflight):
response["Access-Control-Allow-Origin"] = origin
# No Allow-Credentials: the SDK uses a Bearer header, not cookies.
...
This is the single most important line, and it is a line that is not there.
Access-Control-Allow-Credentials: true tells the browser to send cookies with
cross-origin requests. Combined with an echoed Allow-Origin, it hands the
allow-listed site the visitor’s ambient authority: any request it makes
carries the session cookie, so a compromised or malicious allow-listed page acts
as the logged-in user with no further work.
By authenticating the SDK with a Bearer header instead, the embed has to
possess a token. A page that does not have one cannot act as anyone. There is
no ambient authority to ride, which means there is no CSRF against these
endpoints — not mitigated, absent, because the precondition for CSRF is that
the browser attaches credentials automatically.
Worth stating as a rule: cross-origin plus cookies is a combination to avoid rather than to secure. Every CSRF defence is work to make a dangerous arrangement safe. Header-based auth deletes the arrangement.
Note also that the origin is echoed exactly, never *:
response["Access-Control-Allow-Origin"] = origin
Echoing is the only way to grant several specific origins — the header takes one
value. It is also the pattern that becomes a vulnerability the instant you echo
without checking, which is why allowed is computed before anything is written.
Vary: Origin, even when refusing
response = self.get_response(request)
if match:
# Always vary on Origin for collab paths so a cached non-CORS
# response can't mask a later allow-listed one (and vice versa).
patch_vary_headers(response, ("Origin",))
if allowed:
self._apply_cors(response, origin, preflight=False)
return response
patch_vary_headers runs on every collab response, allowed or not.
The bug it prevents is a caching bug and it is nastier than it sounds. A shared
cache — a CDN, a corporate proxy — keys responses by URL. Without Vary: Origin:
- A request from an allowed origin is served with
Access-Control-Allow-Origin: https://a-site.exampleand cached. - A request to the same URL from any other page gets that cached response, complete with a CORS grant to a site it is not.
And the mirror: a refused request caches a header-less response, and the legitimate embed then gets a CORS error that no amount of configuration fixes, until the cache entry expires.
Vary: Origin tells caches the response depends on that request header. It costs
nothing and it is forgotten constantly, because it only breaks in production,
behind infrastructure the developer does not run.
The rule: Vary on every request header your response logic reads. If a
header changed the output, the cache has to know.
The preflight trap
is_preflight = (
request.method == "OPTIONS"
and origin
and match
and request.headers.get("Access-Control-Request-Method")
)
if is_preflight:
response = HttpResponse(status=200)
if allowed:
self._apply_cors(response, origin, preflight=True)
patch_vary_headers(response, ("Origin",))
return response
"""
OPTIONS preflights are answered here (200 + headers when allowed, plain 200
without CORS headers — i.e. browser-blocked — otherwise) instead of falling
through to the DRF view, which would 403 on the participant permission.
"""
This is the trap that costs people an afternoon, and the failure is comprehensively misleading.
A CORS preflight is an OPTIONS request that the browser sends without
credentials — no Authorization header, no cookies. It is a question about
permission to send the real request, not the real request.
Let it reach a permission-gated DRF view and the view does what it should: no
credentials, so 403. The browser sees a non-2xx preflight, blocks the real
request, and reports a CORS error. The developer then spends the afternoon on
CORS configuration, which is correct, while the cause is an authentication check
running on a request that was never supposed to carry authentication.
So the preflight is answered in the middleware, above everything. And the refusal
path is worth reading closely: a disallowed origin still gets 200, just without
CORS headers. Not 403.
That is deliberate, and it reflects what CORS actually is:
"""
a disallowed origin simply gets no CORS headers; the API still works
same-origin and server-to-server exactly as before.
"""
CORS is a browser mechanism, not an authorisation mechanism. The absence of
the headers is what makes the browser refuse to hand the response to the calling
script. Returning 403 would add nothing — curl ignores CORS entirely — while
breaking every legitimate server-to-server and same-origin caller that happens to
send an Origin header.
Authorisation is a separate layer, and it runs in the view where it belongs. The CORS middleware answers one question — may this browser page read this response — and nothing else. Conflating the two produces a system where you cannot reason about either.
Note the third condition on is_preflight:
request.headers.get("Access-Control-Request-Method"). An OPTIONS request
without it is not a preflight — it is a client asking what methods a resource
supports, which is a legitimate thing to do and should reach the view. Three
conditions, so only a real preflight is short-circuited.
Keeping the module importable from both sides
"""Origin normalization + allow-list matching for the collaborative embed.
Kept free of Django-model imports so both ``FormTemplate`` (validation) and the
CORS middleware (request-time check) can use it without a circular import.
"""
The origin logic is used twice: when an administrator saves an allow-list entry, and on every request. Those live on opposite sides of the model layer, so putting the logic in either one creates a cycle.
The result is a module with one import — urllib.parse.urlsplit — no Django, no
models, no settings. Which means it is testable without a database and reusable
anywhere, and it is the shape to reach for whenever the same rule has to be
enforced at write time and at read time.
The model import that is needed is deferred into the method:
def _origin_allowed(self, access_code, origin):
from .models_collaborative import CollaborativeSession
Middleware is instantiated during application startup, and a module-level model
import there is the classic AppRegistryNotReady.
One honest cost: _origin_allowed runs a query per collab request that carries an
Origin header. It is bounded — only that path prefix, only cross-origin
callers — and it is the natural thing to cache on the access code if it ever
shows up in a profile. Worth knowing it is there rather than discovering it.
What to take away
- Classify endpoints by effect, not by verb.
*is fine on a feed that is already public and unacceptable on anything that creates state. - Put the allow-list on the resource when the resources belong to different customers. A settings constant cannot express “may A write to B’s form”.
- Per-resource CORS needs the resource in the path, because preflights have no body.
- Normalise origins and match exactly. No wildcards, no port-agnostic fallback, and reject userinfo, path, query and fragment at entry.
- Do not send
Allow-Credentials. Authenticate with a header and CSRF stops being a problem you have to solve. Vary: Originon every response whose logic reads it, including refusals.- Answer preflights before authentication. An
OPTIONScarries no credentials, and a 403 on it surfaces as a CORS error that sends you looking in the wrong place. - Refuse by omitting headers, not by returning 403. CORS constrains browsers; authorisation belongs in the view.