Bypassing Pangolin access rules using URL-encoded path traversal

Using URL-encoded path separators to bypass Pangolin < v1.21.0's path-based access rules

Background

I found a vulnerability in Pangolin, an open-source reverse proxy, tunneling server, and VPN. It basically serves as a selfhosted alternative to Cloudflare Tunnels and Tailscale. The feature that matters for this is the reverse proxy. Pangolin runs on one system, typically a VPS, and reverse proxies traffic to services on other systems over WireGuard.

Pangolin is identity-aware. You can enable authentication for sites, requiring a user to have Pangolin credentials to access them. Using access rules, you can add more granular controls on a site, like bypassing auth or blocking traffic for specific paths.

Vulnerability

This vulnerability affects Pangolin < v1.21.0 in two configurations:

  • A site with “Block Access” enabled for specific pages, where at least one other page is allowed without auth. A path traversal payload starting with an allowed page and ending with the blocked page will reach the blocked page.
  • A site with a “Bypass Auth” rule containing a path ending with a wildcard and auth enabled for other areas of the site. Path traversal can be used to access all other pages by appending the path traversal payload and target path to the allowed path.

By including a path traversal sequence with encoded slashes, the path will be decoded, but not normalized prior to reaching Pangolin rule matching, which itself does not normalize paths. This issue allows for a wildcard bypass rule to match paths outside of its scope, as well as for a “Block Access” rule to fail to match paths meant to be blocked. If the target backend is one that decodes and normalizes paths, such as Python http.server, Nginx, and Caddy, this will allow access to blocked paths.

This is caused by Pangolin’s auth relying on Traefik’s sanitization for blocking path traversal, which follows RFC 3986 and does not handle encoded slashes, as Traefik’s documentation explains. The docs recommend disallowing all encoded characters in Traefik’s configuration if your backend is not fully compliant with RFC 3986. Pangolin’s default Traefik configuration explicitly enables allowEncodedSlash.

PoC

This was demonstrated against a site on a Pangolin instance running v1.20.0 with the following access rule:

Screenshot of the added access rule. Priority 1, Action “Bypass Auth”, Value “public/*”, enabled.

The unencoded traversal is redirected to auth, while the encoded traversal directly reaches the endpoint.

$ curl -i --path-as-is https://pocapp.test.quasigod.xyz/public/../admin/
HTTP/2 302
alt-svc: h3=":443"; ma=2592000
location: https://pangolin.test.quasigod.xyz/auth/resource/0c3a9ccd-7b5b-4161-b896-401a24e47f15?redirect=https%3A%2F%2Fpocapp.test.quasigod.xyz%2Fadmin%2F
content-length: 0

$ curl -i https://pocapp.test.quasigod.xyz/public/..%2Fadmin/
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: text/html
server: SimpleHTTP/0.6 Python/3.14.6
content-length: 22

<h1>ADMIN SECRET</h1>

The same request also bypasses this rule: Screenshot of another access rule. Priority 1, Action “Block Access”, Value “admin/*”, enabled. Here, the unencoded traversal is blocked, while the encoded traversal reaches the admin endpoint.

$ curl -i --path-as-is https://pocapp.test.quasigod.xyz/public/../admin/
HTTP/2 401
alt-svc: h3=":443"; ma=2592000
content-type: text/plain; charset=utf-8
x-content-type-options: nosniff
content-length: 13

Unauthorized

$ curl -i https://pocapp.test.quasigod.xyz/public/..%2Fadmin/
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: text/html
server: SimpleHTTP/0.6 Python/3.14.6
content-length: 22

<h1>ADMIN SECRET</h1>

Impact

This vulnerability allows a user to bypass Pangolin’s access rules and reach a page that was meant to be protected. When a “Bypass Auth” wildcard rule is abused, an authenticated page can be reached without logging in. When a “Block Access” rule is evaded, a path that an administrator explicitly blocked can be reached without authentication. In a case where sensitive data or controls are placed behind the affected rule, an unauthenticated user would be able to trivially access them.

Reporting

I reported this vulnerability to Pangolin on July 17, 2026 and it was fixed on July 19. This fix was included in v1.21.0 without any credit or notice in the changelog. GitHub requires maintainer cooperation to get a CVE, so I asked the security team to accept my report and request one. They did not respond, so I have instead requested a CVE from MITRE, which can take a good while to get reviewed.

Pangolin’s website also mentioned a bug bounty program at the time I submitted my report, which has since been removed. The team did not respond to my questions about a bounty either.

Breakdown

After finding this vulnerability, I started reviewing Pangolin’s path matching code to see why it occurs. I initially assumed Pangolin had just implemented naive path normalization that did not handle encoded paths, but it turned out the code had no logic for decoding or sanitizing the paths at all, yet somehow unencoded path traversal attempts were being blocked. This caught my interest and I wanted to know exactly how the paths reached Pangolin’s path checking code, so below I trace it throughout each component.

When you send a request to Pangolin, it is handled by multiple components. Pangolin relies on Traefik as its reverse proxy under the hood. It uses a middleware plugin for Traefik named Badger, which is how Pangolin authentication is integrated. The fact that multiple components are handling the encoded URL in different ways is a common way for this class of vulnerability to occur.

Go’s URL type

Go’s net/http lib provides the URL type used by Traefik. This URL type contains both Path and RawPath. RawPath exists to store the initial URL text exactly. RawPath is kept only when the received text differs from Go’s own encoding of Path. The function url.EscapedPath returns RawPath when it is present, otherwise it encodes Path itself.

requestURLEscapedPath()GET /public/../admin/Path = /public/../admin/RawPath = <empty>/public/../admin/GET /public/..%2Fadmin/Path = /public/../admin/RawPath = /public/..%2Fadmin//public/..%2Fadmin/

Traefik’s path handling

pkg/server/server_entrypoint_tcp.go:691-697

if configuration.HTTP.SanitizePath != nil && *configuration.HTTP.SanitizePath {
      handler = sanitizePath(handler)
}

handler = normalizePath(handler)

Each assignment wraps the handler from the line above it, meaning a request passes through normalizePath, then sanitizePath.

Normalization runs on the RawPath if it exists, otherwise does nothing. It decodes the path, but skips a list of reserved characters, which includes %2F. It directly sets RawPath to this value.

Sanitization gets url.EscapedPath and runs joinPath, which handles path segments that are literally . or ... However, since %2F was not decoded by normalizePath, segments containing it, such as /public/..%2Fadmin/, will not be handled.

Since sanitization might change the path structure, it runs setPath, setting the URL object’s Path and RawPath based on the sanitized path.

The end result of this is that an unencoded path traversal would be sanitized, but an encoded path traversal would not.

inputnormalizePathsanitizePath/public/../admin//public/../admin//admin//public/..%2Fadmin//public/..%2Fadmin//public/..%2Fadmin/

Pangolin access rule matching

Before the request is proxied to the target resource, the Badger plugin will send it to Pangolin for authentication checks. The plugin sends req.URL.Path, which is the decoded path.

Prior to v1.21.0, Pangolin’s path matching code did not do any sanitization or normalization. This means that in the “Bypass Auth” example, when /public/../admin/ reaches Pangolin auth, the wildcard in the public/* rule matched .. + admin as segments under public and the rule allowed the request through without authentication. In the “Block Access” example, access to public/* is allowed by default, and the admin/* rule fails to match. Basic path traversal would be sanitized by Traefik and never reach Pangolin, but encoded traversal reaches Pangolin without being collapsed.

Path reaching the matcher Bypass Auth — public/* Block Access — admin/*
/admin/ no match → auth required match → blocked
/public/../admin/ match → auth bypassed no match → allowed

Proxying

Lastly, in the case where the Pangolin auth passes, the proxy forwards RequestURI, built from EscapedPath. This means that the webserver receives the RawPath if it exists, with the original encoding. If the webserver decodes and normalizes URLs, it will result in path traversal.

sanitizePathRequestURIWebserver response/public/..%2Fadmin//public/..%2Fadmin//admin/

As we can see, with either rule, the encoded traversal makes it past Pangolin’s auth and reaches a path that was meant to be off-limits.