info@altius-group.ch
Froideville, Vaud
IT

CORS for a write surface

/ 12 min di lettura / aggiornato 04.09.2026

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.example and 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

  1. Classify endpoints by effect, not by verb. * is fine on a feed that is already public and unacceptable on anything that creates state.
  2. 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”.
  3. Per-resource CORS needs the resource in the path, because preflights have no body.
  4. Normalise origins and match exactly. No wildcards, no port-agnostic fallback, and reject userinfo, path, query and fragment at entry.
  5. Do not send Allow-Credentials. Authenticate with a header and CSRF stops being a problem you have to solve.
  6. Vary: Origin on every response whose logic reads it, including refusals.
  7. Answer preflights before authentication. An OPTIONS carries no credentials, and a 403 on it surfaces as a CORS error that sends you looking in the wrong place.
  8. Refuse by omitting headers, not by returning 403. CORS constrains browsers; authorisation belongs in the view.
Pronto a cominciare?

Parliamo del suo progetto

Ci racconti le sue esigenze in IoT, GIS o sviluppo su misura — le rispondiamo entro 24 ore.