Backend
nginx silently rejects the new HTTP QUERY method
Alex Georgiev DEV Community
2 views
RFC 10008 went to Proposed Standard in June. It adds QUERY, a new HTTP method. Safe and idempotent like GET, but it carries a body like POST. On paper, that's it. A new verb.
I nearly didn't bother writing this up because of that. Then I read further into the RFC and found a line saying older proxies, frameworks and load balancer configs might not recognise the method yet. It doesn't say which ones. It doesn't say what "not recognise" even means in practice. Does it 404? 405? Does it just eat the body and treat it as GET? Nobody tells you, so I rented a box and found out myself.
Most codebases I've touched have a POST /search somewhere, and it's always for the same reason: GET can't carry a real filter object, and nobody fancies fighting a URL length limit over it. QUERY fixes that, in theory. Whether it works in practice depends on every layer between the client and your view agreeing to let the method through. That's not something the RFC can tell you. Only running it can.
So that's what I did. One backend, three reverse proxies in front of it, one separate Django app on the side, and a droplet I could throw away the second I had numbers.
What I built
A FastAPI backend on port 8001. nginx, Caddy and Traefik each fronting it on their own port. A separate Django project too, with two class-based views, because Django isn't built on Starlette and dispatches methods completely differently. All of it on one DigitalOcean Droplet in Frankfurt, fra1, s-2vcpu-4gb, Ubuntu 24.04. I killed the droplet the moment testing was done.
Versions, in case you're checking this later: curl 8.5.0. FastAPI 0.141.1 on Starlette 1.6.0. Django 6.1.1. nginx 1.24.0. Caddy 2.11.4. Traefik 3.7.10.
curl already does this properly
First question, before anything else: can the tooling even send a QUERY request with a body? I pointed curl at a bare netcat listener to see the raw bytes.
curl -s -m 2 -X QUERY -H "Content-Type: application/json" \
-d '{"q":"test"}' http://127.0.0.1:8000/search
QUERY /search HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: curl/8.5.0
Accept: */*
Content-Type: application/json
Content-Length: 12
{"q":"test"}
No special flag. No workaround. curl just sends whatever method you give it through -X, body and all. It didn't need anything. That's one layer down.
FastAPI
I gave it a route with methods=["QUERY"]:
@app.api_route("/search", methods=["QUERY"])
async def search(request: Request):
body = await request.body()
return JSONResponse({"received_method": request.method, "body": body.decode()})
That worked. 200, body echoed back. Not surprising, since I'd told it to expect QUERY. What I actually wanted to know was what happens on a route I hadn't touched. So I sent the same request to /docs, which only has GET on it:
HTTP/1.1 405 Method Not Allowed
allow: GET, HEAD
content-type: application/json
{"detail":"Method Not Allowed"}
Correct Allow header, listing GET and HEAD. Nothing broke, and I'll say that plainly since most of this post is about things that did break. But it also means QUERY doesn't just piggyback on your GET handler. You want ten routes to answer QUERY, that's ten edits. Not a flag, not a setting.
Django: I wrote a handler, and it still said no
Django's class-based views dispatch by looking up a lowercased method name. get for GET. post for POST. So query should work for QUERY the same way. I wrote one:
class SearchView(View):
def query(self, request, *args, **kwargs):
return JsonResponse({"received_method": request.method,
"body": request.body.decode()})
HTTP/1.1 405 Method Not Allowed
Allow: OPTIONS
Content-Length: 0
Refused. Empty body. An Allow header that doesn't even mention the method I just wrote a handler for, and only lists OPTIONS, because OPTIONS is the one method this class gets automatically and I hadn't implemented get or post on it either. Here's why it happened: View.http_method_names is a hardcoded list, and Django checks the request against it before it ever looks at your class.
>>> from django.views import View
>>> View.http_method_names
['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']
No query in there. Doesn't matter that I wrote the method. Django never gets that far. One line fixes it, but you have to know to write it:
class SearchViewFixed(View):
http_method_names = View.http_method_names + ["query"]
def query(self, request, *args, **kwargs):
return JsonResponse({"received_method": request.method,
"body": request.body.decode()})
HTTP/1.1 200 OK
Content-Type: application/json
{"received_method": "QUERY", "body": "{\"q\":\"hello\"}"}
Same handler. Same request. The only change is telling the class it's allowed to answer at all. This is the sharp edge of that RFC warning I mentioned. It doesn't crash. It just 405s, looking exactly like a typo in your URL, and the traceback won't point you anywhere near the real fix.
nginx, and the config pattern that breaks it
Plain proxy_pass, nothing restricting methods:
location / {
proxy_pass http://127.0.0.1:8001;
}
HTTP/1.1 200 OK
{"received_method":"QUERY","body":"{\"q\":\"hello\"}"}
Works fine. Then I added limit_except, the block that turns up in a huge share of nginx hardening guides, restricting a location to only the methods it's supposed to need:
location / {
limit_except GET POST HEAD {
deny all;
}
proxy_pass http://127.0.0.1:8001;
}
HTTP/1.1 403 Forbidden
Flat. No explanation. Never even reaches the backend. And to be fair to nginx: the default was fine two paragraphs ago. This isn't nginx's fault, it's a hardening snippet copied into configs for years, written back when GET, POST and HEAD covered every method a location would ever need. Whoever wrote it had no reason to think about a verb that didn't exist yet. If your config has limit_except anywhere in it, go look at what's on that list right now.
Caddy and Traefik: nothing to report
I expected at least one of these to have an opinion about a method it didn't recognise. Neither did.
# Caddy, default reverse_proxy, no config changes
HTTP/1.1 200 OK
Via: 1.1 Caddy
{"received_method":"QUERY","body":"{\"q\":\"hello\"}"}
# Traefik, default file-provider router, no config changes
HTTP/1.1 200 OK
{"received_method":"QUERY","body":"{\"q\":\"hello\"}"}
They just pass whatever the client sends. No allowlist to trip over. Nothing to configure. Caddy and Traefik are also the two newer tools of the three here, which fits the RFC's "older tooling" line better than I expected going in.
Under load, and in the logs
Ten concurrent QUERY requests through nginx's plain proxy, all 200. No concurrency surprise there. The access log picked them up cleanly too, no config change needed:
127.0.0.1 - - [09/Sep/2026:19:23:13 +0000] "QUERY /search HTTP/1.1" 200 51 "-" "curl/8.5.0"
Request line, status, size, all where you'd expect them. If you're watching this in production already, your log pipeline already sees it fine. The gap isn't observability. It's earlier, at the config and framework layer, before the request even gets that far.
What I got wrong
My first Traefik config wouldn't load. The error wasn't helpful: yaml: line 4: found unknown escape character. I'd written the router rule as one long string passed straight through an SSH command, and the backtick in PathPrefix(`/`) got mangled by an extra layer of shell escaping I hadn't planned for. Writing the same YAML through a quoted heredoc over ssh ... bash -s, instead of one inline string, fixed it straight away. That one was on me, not Traefik.
Run it yourself
Five checks, that's all of this. Every command below is exactly what I ran, against a backend already listening on port 8001.
# 1. curl sends QUERY with a body natively
curl -s -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8001/search
# 2. Explicit FastAPI route works; unregistered route doesn't
curl -s -i -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8001/search
curl -s -i -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8001/docs
# 3. Django: default View rejects it, extended http_method_names accepts it
curl -s -i -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8002/search/
curl -s -i -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8002/search-fixed/
# 4. nginx: plain proxy_pass works, limit_except blocks it
curl -s -i -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8010/search # plain
curl -s -i -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8011/search # limit_except
# 5. Caddy and Traefik, both unmodified
curl -s -i -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8020/search
curl -s -i -X QUERY -d '{"q":"hello"}' http://127.0.0.1:8030/search
The Django fix is that one http_method_names line, plus the query method itself. The nginx fix is either drop limit_except, or add QUERY to its list by hand.
What to do about it
Don't test this on a clean install of anything. I did, here, and it made the whole thing look easier than it will be on a real system. Grep your actual nginx config for limit_except first. That one line is what decided whether QUERY got anywhere near my backend at all. On Django, check http_method_names on the actual views you'd be changing, not some throwaway subclass.
Going in, I expected the framework side to be the messy one. I came out thinking the proxy layer is worse, just because it's older, more copied from tutorial to tutorial, and less likely to get a second look before this breaks on someone. curl got QUERY right before I'd written a single line of my own code. A five year old nginx snippet didn't.
That's the whole shape of it. The method itself is sound, and that POST /search I mentioned at the start really can become a QUERY without changing what it does. It just can't skip the audit of everything sitting in front of it first.
Read original: https://dev.to/alexgeorgiev17/nginxs-limitexcept-block-silently-rejects-the-new-http-query-method-1gcg
Related
hey guys
Backend
0
Reddit r/programming
Building Presibo: The Technology Behind Continuous Healthcare
Backend
2
DEV Community
Hey DEV, I'm Vinayak — a CSE student who got tired of stopping at the notebook
Backend
4
Dev.to (EN Zone)
A Systems Engineer’s Guide to FTTP (Fibre to the Premises) Latency Baselines
Backend
2
DEV Community
Comments0
No comments yet — be the first