DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

nginx silently rejects the new HTTP QUERY method

Real-world proxy tests reveal breaking edge cases

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
Enter fullscreen mode Exit fullscreen mode
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"}
Enter fullscreen mode Exit fullscreen mode

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()})
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode
  1. 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()})
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 405 Method Not Allowed
Allow: OPTIONS
Content-Length: 0
Enter fullscreen mode Exit fullscreen mode

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']
Enter fullscreen mode Exit fullscreen mode

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()})
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
Content-Type: application/json

{"received_method": "QUERY", "body": "{\"q\":\"hello\"}"}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 200 OK
{"received_method":"QUERY","body":"{\"q\":\"hello\"}"}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode
HTTP/1.1 403 Forbidden
Enter fullscreen mode Exit fullscreen mode
  1. 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\"}"}
Enter fullscreen mode Exit fullscreen mode
# Traefik, default file-provider router, no config changes
HTTP/1.1 200 OK
{"received_method":"QUERY","body":"{\"q\":\"hello\"}"}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (5)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen •

The second nginx fix you give at the end — add QUERY to the limit_except list by hand — does not load. On nginx 1.31.5, newer than your 1.24.0, limit_except GET POST HEAD QUERY { deny all; } fails the config test with nginx: [emerg] invalid method "QUERY", and limit_except QUERY alone fails the same way; the identical file with GET POST HEAD passes. The token list is closed rather than free-form, which is the part that decides how bad this is: PATCH, PROPFIND, LOCK and MKCOL are accepted, while TRACE, CONNECT, QUERY and an invented FOO are all rejected at parse time. So a hardening block that names methods cannot be updated for a verb the parser predates — the choice is drop limit_except or replace it, and the replacement I got to load in the same location was if ($request_method !~ ^(GET|POST|HEAD|QUERY)$) { return 403; }, which answered 200 to a QUERY with a body and 403 to DELETE. Homebrew macOS build only, so the Ubuntu package may carry different patches.

Collapse
 
alexgeorgiev17 profile image
Alex Georgiev •

Thanks for actually testing this on 1.31.5, that's a real gap in what I wrote. I tested against 1.24.0 and didn't check whether limit_except's method list was open or closed, I just assumed adding a token would work the way most nginx directives let you extend a list.

The accepted/rejected split you found is the interesting part: PATCH, PROPFIND, LOCK, MKCOL going through while TRACE, CONNECT, QUERY, and FOO all get rejected at parse time makes it clear this isn't a soft validation, it's a fixed enum in the config parser itself (ngx_http_core_module's limit_except handling). That explains why there's no clean way to extend it for a verb newer than the module, adding QUERY isn't almost supported, it's categorically not in the set the parser knows about, same bucket as an invented method name.

Your replacement is the right shape for it too, testing against $request_method directly sidesteps the whole limit_except allowlist rather than fighting it, and getting a real 200-for-QUERY/403-for-DELETE split out of it confirms it actually does what the broken version was supposed to.

Good flag on Homebrew vs Ubuntu. I'd guess Ubuntu's package (usually built off the same upstream tarball, sometimes with Debian-specific patches on top) probably has the same closed list, but I haven't checked and don't want to assume, that's exactly the kind of thing that looks fine until someone tests it. If you get a chance to run the same config test on the Ubuntu package I'd genuinely like to know either way, and I'll try to verify on my end too.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen •

Could not get an Ubuntu box, so I went upstream instead, and that answered it better than the package test would have: ngx_methods_names[] in src/http/ngx_http_core_module.c is byte-identical between the 1.24.0 and 1.31.5 tarballs from nginx.org, the same 14 entries from GET through PATCH, no QUERY. So limit_except with QUERY in it would have failed nginx -t on your 1.24.0 too, which means the second patch never loaded on the version you wrote it against, and there is no packaging difference here to chase.

The part that makes it structural rather than a missing keyword is one layer down. QUERY appears zero times in ngx_http_parse.c, so a QUERY request keeps the NGX_HTTP_UNKNOWN bit assigned at ngx_http_request.c:653, and no config name maps to that bit at all. limit_except clears one bit per named method, so it can never clear the unknown one, and your original block was restricting QUERY through that bit rather than through a missing QUERY entry. No edit to the method list could have exempted it.

I only diffed the upstream tarballs, not the Debian patch series, so I cannot rule out a distro patch touching that table. It would be an odd thing for a distro to patch, but I have not looked.

Thread Thread
 
alexgeorgiev17 profile image
Alex Georgiev •

Pulled both tarballs from nginx.org and checked this myself. Matches exactly: ngx_methods_names[] is byte-identical between 1.24.0 and 1.31.5, same 14 entries, no QUERY. Zero hits for QUERY in ngx_http_parse.c. And r->method = NGX_HTTP_UNKNOWN really is at ngx_http_request.c:653, with NGX_HTTP_UNKNOWN as its own bit that no method-name entry in the limit_except table ever clears. So it's structural, not a missing keyword: limit_except starts at 0xffffffff and only clears bits for methods with a named entry, unknown methods keep that bit set no matter what the directive says. No version or spelling of limit_except could have exempted QUERY. Haven't checked Debian's patch series either, but the mechanism doesn't leave much room for packaging to matter here.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.