State-changing API endpoints must require POST

Best practice Recommended
An #/API/ endpoint that changes something - saveFile, deleteFile, sending mail, writing a document - runs just as willingly on a GET as on a POST unless the script refuses. That matters because Docly's access_token cookie is SameSite=Lax, and browsers do send a Lax cookie on a top-level cross-site GET. A plain link or an <img> tag on another site can therefore fire a signed-in visitor's write endpoint with their session attached, which is CSRF; the same link cannot do it with POST, because a Lax cookie is not sent on a cross-site POST. Make the first statement of every state-changing endpoint: if (request.method != "POST") throw new Error("Verb not supported"); The platform upper-cases request.method, so the comparison is safe against a client sending "post", and the thrown Error reaches the caller as HTTP 403 with a JSON body. One catch: request.method is null when a script runs with no HTTP request at all - a scheduled service task - so split the file into an endpoint and a service task rather than loosening the guard to let that case through - and never with optional chaining, which turns the guard into a silent bypass.
Applies to: API endpointsSecurityPublished sites

What you'll see

You write an endpoint under #/API/ that changes something - saves a document, deletes a row, sends a mail - and test it the way you will call it:

fetch('/API/deleteComment', { method: 'POST', body: ... })

It works, so you move on. Nothing tells you that the same endpoint also answers this:

https://yoursite.example/API/deleteComment?id=42

Pasted into a browser it runs. Put in a link in an e-mail, or in an <img src> on a page your visitors read, it runs as them - and the first sign of trouble is data that changed without anyone submitting a form.

What's actually happening

Two facts combine, and neither is a bug on its own.

1. An endpoint answers every verb until it says otherwise

A file under #/API/ is executed for whatever request reaches it. There is no method declaration, no route table and no attribute - so saveFile and deleteFile run on a GET exactly as they do on a POST. Whether the endpoint is state-changing is something only the script knows.

2. The session cookie rides along on a cross-site GET

Docly's access_token cookie is set Secure, HttpOnly and SameSite=Lax. Lax is the important one, and it is deliberately asymmetric:

Request from another siteIs access_token sent?
Top-level GET - a link the visitor clicks, a redirectyes
Cross-site POST - a form or fetch from another originno

So the reachable verb is exactly the one an unguarded endpoint is happy to accept. An attacker does not need to read your responses or defeat CORS; a one-way write is enough. That is cross-site request forgery, and requiring POST closes it, because the browser then withholds the cookie the endpoint depends on.

request.method exists in Docly for this purpose. Before it, a script could not tell the two apart at all.

Why the comparison is safe

request.method is upper-cased by the platform, so != "POST" means what it looks like. That normalisation is not cosmetic. The request pipeline decides what counts as a POST case-insensitively, so a client sending post is routed as a POST; a script comparing the raw value against "POST" would have seen "post", judged it not-a-POST... and in a guard written the other way round, waved it through. Normalising in one place removes the mismatch.

What to do

Put the guard first

Before any read of form, before any write, as the opening statement of the file:

if (request.method != "POST") throw new Error("Verb not supported");

Nothing after it can run on a GET, which is the property you want - a guard placed after the first saveFile protects nothing.

What the caller gets

HTTP 403, with the error object as the JSON body:

{"name":"Error","message":"Verb not supported"}

Not 405. Docly serialises a thrown object straight to the response and answers 403 for all of them, so do not go looking for a Method Not Allowed - and do not write client code that switches on 405. If you want a machine-readable reason, throw an object of your own and read the fields: throw { error: "verb", allow: "POST" }; answers 403 with exactly that body.

Which endpoints

Every endpoint that changes state or has a side effect: writing or deleting a document, sending mail, charging something, mutating configuration. Endpoints that only read can stay on GET - they are cacheable and linkable, and that is worth keeping.

If an endpoint both reads and writes depending on its input, split it. A single endpoint that is sometimes safe is one you cannot reason about later.

The one exception: no HTTP request at all

request.method is null when a script runs outside a request - a scheduled service task executes the file with no HTTP context. The strict guard rejects that, because null != "POST" is true, and the scheduled job starts failing for a reason that looks nothing like its cause.

Split the file rather than loosen the guard. Put the work in an include and give the two callers their own entry point: the endpoint keeps the strict guard, and the service task needs no guard because it never serves a request. That is the same rule as above - an endpoint that is sometimes safe is one you cannot reason about later - and it leaves nothing to weigh up.

If you keep one file anyway, the lenient form is:

if (request.method && request.method != "POST") throw new Error("Verb not supported");

Know what it gives up. It reads if there is a method and it is not POST, so a missing method is allowed through: the default on an unrecognised state becomes allow, on the endpoint least able to afford it. The falsy branch is wider than «no HTTP context» too - the platform normalises null, empty and whitespace-only alike to null, so anything arriving without a usable method lands in the same allow. That branch is only as safe as the guarantee that a real request always carries a method, and the guarantee lives in the pipeline, not in your file.

Never write it with optional chaining. request?.method && ... looks like it hardens the line and does the opposite: if request were ever missing, the expression short-circuits to undefined, the condition is false, and the state-changing endpoint runs. A crash would have been the better outcome. request is in any case always defined - the platform sets it for requests, for service tasks and for the compile check alike, and it is method that is null, not the object. To cover a missing request, fail closed: if (!request || request.method != "POST").

One reason that matters more than it looks: reading a member of a nullish value throws a TypeError only in a .js file. In .hash template mode the same read returns null silently, by design, so there the mistake gives you the quiet wrong answer rather than the loud one.

The guard costs nothing at compile time. Compilation parses the file without executing it, so a strict guard cannot break the site's compile check.

This is hardening, not authorisation

Requiring POST stops a third-party site from making the browser act on the visitor's behalf. It says nothing about whether this visitor may do the thing at all. Keep the permission check as well - see Keep private data under the # folder. Both, always: one guards who asked, the other guards who is asking.