
Nginx 路径解析缺陷:编码反斜杠可绕过访问控制访问受保护路由
来源标题:Backslashes can accidentally allow users to access protected routes
安全研究者通过 750 次 HTTP 请求测试发现,一个编码反斜杠(%5C)能绕过 Nginx 的 403 访问规则,使请求抵达本应被拦截的 Node.js 处理程序。原因是 Nginx 与 Node.js 对 URL 的规范化理解不一致:Nginx 看到的路径是 /admin%5Cdashboard,Node.js 却将其解析为 /admin/dashboard 并执行了对应处理器。
以下正文同步自 Hacker News 最新,版权归原站所有,已转换为易读排版。
I sent 750 HTTP requests through 10 server configurations. One encoded backslash passed an Nginx guard and reached the Node handler it was meant to block. How URL normalization turns a reasonable access rule into a boundary that protects the wrong thing.
I sent 750 HTTP requests through 10 server configurations. One encoded backslash exposed a surprisingly dangerous assumption about how web applications handle URLs.
123
location = /admin/dashboard { return 403; }
Then I sent a request:
12
GET /admin/dashboard HTTP/1.1 Host: localhost
Nginx returned 403 Forbidden.
12
GET /admin%5Cdashboard HTTP/1.1 Host: localhost
This time, Nginx returned 200 OK.
Even more interesting, the Node.js application behind Nginx executed the handler for /admin/dashboard.
123456
{ "edge_original": "/admin%5Cdashboard", "edge_normalized": "/admin\dashboard", "path_seen": "/admin/dashboard", "route": "admin-dashboard" }
The Nginx rule was still there. It was doing exactly what I configured it to do.
Node.js was also doing exactly what I programmed it to do.
The problem was that they disagreed about which URL the client had requested.
I built this example with a deliberately simple edge-only access rule and a harmless backend that returns diagnostic JSON. The goal was to expose what happens when two components interpret the same request differently.
Nginx decided the request was allowed. Node decided it belonged to the admin route.
That's the kind of disagreement that can turn an otherwise reasonable security check into a vulnerability.
The sequence %5C is the percent-encoded representation of a backslash ().
It's easy to overlook because backslashes aren't typically used as separators in HTTP paths. Most developers expect forward slashes.
But URL parsers don't all agree about how backslashes should be handled.
Let's follow the request through the system.
The Nginx configuration had two locations:
1234567
location = /admin/dashboard { return 403; }
location / { proxy_pass http://127.0.0.1:3000$uri$is_args$args; }
The exact location blocks the admin path. Everything else is forwarded to the backend.
1
/admin%5Cdashboard
It decodes the percent-encoded character while constructing its normalized URI.
1
/admin\dashboard
Nginx compares that value against its configured locations.
It's different from /admin/dashboard, so the exact match doesn't apply.
Our proxy configuration forwards the normalized $uri value to Node.js.
1
/admin\dashboard
Now consider the backend:
123456789101112131415
const http = require('node:http');
http.createServer((req, res) => { const path = new URL( req.url, 'http://localhost' ).pathname;
const route = path === '/admin/dashboard' ? 'admin-dashboard' : 'fallback';
res.end(JSON.stringify({ path, route })); });
The critical line is:
1
new URL(req.url, 'http://localhost').pathname
Node's WHATWG URL parser follows the URL Standard.
For special URL schemes such as HTTP, that parser treats backslashes as path separators.
1
/admin/dashboard
The backend matches the admin route.
And that's how the same request passes the proxy's guard and reaches the route the guard was supposed to protect.
Both implementations have documented behavior. The application made an unsafe assumption about their agreement.
Here are the results from the Nginx guard experiment:
Nginx recognizes the normalized path as /admin/dashboard and blocks it.
But %5C, the encoded backslash, takes a different route through the system.
The proxy interprets it as a backslash. The backend subsequently interprets it as a path separator.
The important observation is that a request's meaning can change after an access-control decision has already been made.
Security decisions are only useful when they apply to the operation the system ultimately performs.
If the authorization layer and the execution layer disagree about the operation, the authorization check may be protecting the wrong thing.
After finding the backslash behavior, I expanded the experiment.
I sent 25 HTTP request variants through 10 server configurations, repeating each case three times.
Several server families were tested under different configurations, including two Go routing approaches and three Nginx proxy modes.
The corpus included encoded slashes, backslashes, duplicate slashes, dot segments, semicolons, and absolute-form request targets.
All 750 requests received responses, with no transport errors. Each server-and-request combination produced the same result across all three repetitions.
The most interesting disagreements weren't necessarily HTTP errors.
They were cases where multiple implementations accepted the same request but disagreed about which route it represented.
12
GET /admin%2Fdashboard HTTP/1.1 Host: localhost
Here's what happened:
The disagreement exists because each component has its own rules for decoding, normalizing, and matching paths.
Those rules are frequently reasonable when considered individually.
The trouble begins when security depends on one component predicting another component's interpretation.
One of the more interesting results came from Go.
The difference wasn't between two programming languages.
It was between two routing approaches in the same language.
Go's net/http package exposes the decoded URL path through r.URL.Path.
1
/admin%2Fdashboard
The decoded URL.Path contains:
1
/admin/dashboard
So an application that compares the decoded path directly can recognize it as the admin route.
But Go's http.ServeMux has different matching semantics.
According to the Go documentation, escaped path elements are preserved for routing purposes. An encoded slash isn't treated as an ordinary separator when matching route segments.
Consequently, a ServeMux handler registered for /admin/dashboard doesn't match that encoded-slash request in the same way as a handler that compares the decoded URL.Path.
Now imagine an application that authorizes requests using one interpretation and dispatches them using the other.
The developer believes they're comparing the same path.
This is why authorization should be tied to the actual route or operation being executed, rather than a separate interpretation of the incoming URL.
Most developers don't think of URL normalization as a security-sensitive operation.
It's something frameworks and web servers do for convenience.
But every normalization step can change how an input is interpreted.
1234
/admin/dashboard /admin%2Fdashboard /admin%5Cdashboard //admin/dashboard
Depending on the component and configuration, these can represent the same route, different routes, or invalid requests.
The distinction matters when different components make different decisions.
An edge proxy might enforce a restriction before a request reaches the application.
A middleware layer might check whether the path begins with /admin.
A router might then normalize the path again before selecting a handler.
If those components don't agree, the relationship between authorization and execution can break.
And this isn't a purely theoretical concern.
On October 27, 2026, the Fastify maintainers published a critical security advisory for @fastify/express.
The vulnerability affected versions 4.0.5 through 4.0.8 under a particular non-default routing configuration.
The root cause was another URL interpretation disagreement.
Fastify's router and the Express middleware integration handled an absolute-form request target differently when duplicate-slash normalization was enabled.
That difference could allow a request to reach a protected handler while skipping path-scoped middleware responsible for authentication, authorization, or rate limiting.
The mechanism differed from our Nginx and Node example, but the underlying security principle was the same.
One component evaluated the request using one interpretation.
Another component executed it using another.
When a security policy depends on both interpretations being equivalent, the disagreement creates an opportunity for bypass.
This is exactly why path normalization deserves the same attention as other security-sensitive input handling.
Suppose a request travels through three layers:
12345678910111213
Client | v Reverse Proxy | v Middleware | v Application Router | v Handler
Each layer may parse or normalize some part of the request.
If the reverse proxy is the only component enforcing access to /admin/dashboard, then its interpretation of the path becomes part of the application's security model.
If the backend can subsequently interpret an allowed path as /admin/dashboard, the boundary is fragile.
A stronger design enforces authorization against the operation the backend has actually resolved.
12345
if (route === 'admin-dashboard') { requireAuthenticatedAdmin(request); }
executeRoute(route, request);
This is conceptual code, but the placement matters.
The application determines which operation will execute, then checks whether the caller is authorized to perform that operation.
Proxies can still provide useful access controls, rate limits, and defense in depth.
But an application's critical authorization rules should remain effective even when an upstream component interprets a URL differently.
There are a few practical principles worth following:
-
Authorize the resolved operation. Check permissions against the route, resource, or action the application will actually execute.
-
Reject ambiguous request targets. When possible, reject malformed or unusual encodings before they pass through multiple interpretation layers.
-
Avoid independent path parsing for security decisions. If middleware uses one parser and the router uses another, verify that their behavior is compatible.
-
Test the complete request pipeline. A unit test for Nginx and a separate unit test for a backend won't necessarily reveal a disagreement between them.
-
Include unusual path representations in regression tests. Encoded slashes, backslashes, dot segments, duplicate separators, and absolute-form targets are worth testing wherever routing and authorization interact.
The operation you authorize must be the operation you execute.
The experiment uses a local test harness that launches the server configurations, sends raw HTTP/1.1 requests, and records each response.
The requests are sent over loopback, and the demo handlers return diagnostic JSON.
12345
python3 run_lab.py
--out results/my-run
--repeats 3
python3 verify_results.py results/my-run
The harness records the original request target, the path seen by the backend, the selected route, HTTP status, and the proxy's normalization details.
The resulting data makes it possible to compare how each component interpreted the request.
For the backslash example, the most useful fields were:
12345678
{ "edge_route": "fallback-proxy", "edge_original": "/admin%5Cdashboard", "edge_normalized": "/admin\dashboard", "request_target": "/admin\dashboard", "path_seen": "/admin/dashboard", "route": "admin-dashboard" }
This is the entire problem captured in a few lines.
The request crossed the boundary between them and changed meaning.
The surprising thing about this experiment is how little complexity was required.
One encoded backslash was enough to make two ordinary pieces of software disagree about a route.
The Nginx configuration was intentionally simple, and the Node backend was a harmless demonstration. There was no newly discovered vulnerability in either product.
But the integration mistake is real and reproducible.
It illustrates a broader problem that can appear anywhere multiple components interpret the same untrusted input.
Reverse proxies, API gateways, web frameworks, authentication middleware, and application routers all participate in processing HTTP requests.
Each has rules for deciding what a URL means.
Those rules are part of the system's security model, whether devel
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。