State-changing API endpoints must require POST
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 site | Is access_token sent? |
|---|---|
| Top-level GET - a link the visitor clicks, a redirect | yes |
| Cross-site POST - a form or fetch from another origin | no |
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.