Repository navigation
feat(traefik): answer requests for secret files with 404 in Traefik - #67
Merged
Merged
Conversation
Scanners ask every deployed site for /.env, /.git/config, /.aws/credentials, /proc/self/environ and similar files. In 72 hours of one server's access log about 5,000 of 92,000 requests were such probes, and every one of them was forwarded to a deployed application. An application that serves its working directory by mistake would hand the secrets over. The panel now creates a Traefik dynamic file, probe-shield.yml, next to the other default files. It holds one HTTP router on the websecure entry point with no Host in its rule, so it covers every host, and with the highest priority a user router may take (MaxInt32 - 1000), so no panel-generated or hand-written router wins over it. The rule is a PathRegexp that matches, case insensitively and at any depth: a segment starting with .env; the directories .git .aws .ssh .svn .hg .docker .kube; the files .git-credentials .gitconfig .DS_Store .htpasswd .htaccess .npmrc .netrc .pgpass .bash_history; and /proc/self/. /.well-known/, .gitignore, .github/ and .php stay untouched. The separator before and after a name also accepts %2F, %5C and a backslash, because Traefik keeps %2F encoded in the path it matches and a backend may decode it (/static/..%2F..%2F.env reached the backend before this was added). Traefik has no "return status" middleware, so the router uses service noop@internal behind an ipAllowList middleware whose only allowed range is 0.0.0.0/32 and whose rejectStatusCode is 404. The router carries an explicit empty tls section so the entry point's certResolver is not applied to it. Port 80 is not used and no HTTP challenge is added: certificates still renew over 443 only. The file is created only when it does not exist (open with O_EXCL), so an admin who edits it, or replaces it with a comment line, keeps that choice. That is the off switch of this first version; the header comment of the file says how to turn it off and how to skip one host. The creation runs on every panel start next to createDefaultMiddlewares, so an existing installation gets the file on the first start after the update, and a fresh installation gets it on the panel's first start. It also runs in setup.ts. A failure to write the file is logged and never stops the panel. Remote servers get the same file from the server setup script (a quoted heredoc, because the rule contains backticks), only when the file is missing. A failed write there is a warning and does not abort the setup. Verified against a real Traefik v3.7.14 container with a file provider, a self-signed certificate, an ACME resolver pointing at an unreachable CA and a whoami backend: probes got 404 without reaching the backend, normal paths reached it, a router with priority 5000 on the same host did not win, SNI still selected the right certificate, and no ACME request was made for the shield. Unit tests cover the generated file, the path pattern, the create-only-when-missing behaviour and the shell step. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The test check of this pull request failed on three tests the change does not touch, while a second pull request was running its checks on the same runners: - "restores the previous Traefik image when the new one is not running" in server-components.test.ts timed out at the default 5 s. The tests of that group run the generated update script in a real shell; alone they take 0.7 to 1.8 s each. - "converts the Traefik config on the server before the standalone Traefik is recreated" in write-traefik-setup.test.ts timed out at 5 s. It is the first test of the file and pays for loading the modules; alone it takes 0.75 s. - The next test of that file then failed because the timed-out test was still running and called a mock the second test expects to stay unused. Both groups now have a 60 s limit, the same value the deletion script suites got earlier. Locally both files pass, before and after. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first version of the shield answered 404 to every path with a secret
file name, which also hid files that Git services show on purpose, such as
.env.example, .gitignore, .github/workflows and .ssh/config in a repository
view. Excluding the whole host by hand was the only way out.
The rule now lets a secret-looking name through only after a route shape
that a product defines for showing repository or synced files:
- Gitea and Forgejo web UI: src, raw, render, commits, blame, media,
tree-list and tree-view followed by branch, tag or commit and a ref; the
_edit, _new, _delete, _upload, _diffpatch and _preview routes; lastcommit
and _cherrypick.
- Gitea and Forgejo API: /api/v1/repos/{owner}/{repo}/contents, contents-ext,
raw, media and editorconfig.
- GitLab: /-/blob, raw, tree, blame, edit, new, update, preview and commits
with a ref, the Web IDE, and the repository files API, where an encoded
slash is the normal separator.
- Nextcloud and ownCloud WebDAV under remote.php and public.php.
Each shape is a fixed chain of words that an ordinary site or a static file
tree does not have, and needs a non-empty ref in front of the dot name, so
/.env, /src/.env, /raw/.env, /blob/.env and /api/.env stay blocked. Owner
and group segments cannot start with a dot. A path with an encoded or
backslash separator is never exempt, except in the GitLab files API where
only ".." next to a separator is refused, because Traefik keeps %2F encoded
and decodes %5C, and ..%2F would otherwise walk out of an exempt prefix.
Left blocked on purpose and documented in the generated file: Git smart HTTP
on a repository with a working tree (info/refs with a query is exactly what
a plain file server answers with the file), Gogs and old Gitea URLs without
a branch, tag or commit word, and S3-compatible storage. Those hosts use the
per-host exclusion, which the file header now spells out with the exact line.
Checked with the generated file loaded in Traefik 3.7.14 in front of a real
Gitea (web views, raw, blame, directory view, API contents, clone and push
over HTTPS, scanner paths on the same host still 404), a WebDAV server, an
echo backend for the GitLab shapes, and the unmodified server setup script
run over ssh on a separate Linux machine.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The exemptions for Git web UIs and WebDAV were cancelled only by an encoded slash or backslash. A path such as /owner/repo/src/branch/main/..;/..;/..;/.env kept its exemption and reached the application. Tomcat, Jetty and other servers that drop ";..." from a segment read "..;/" as "..", so on such a backend the request ends at /.env, which the shield answered itself before the exemptions existed. The same held for ..%3B/ (Traefik keeps %3B encoded) and for %252e%252e/ on a backend that decodes twice. An exemption is now cancelled by "..", ";", %3B, %5C, a backslash, %25 or %00 anywhere in the path, and by %2F everywhere except the GitLab files API, where it is the normal separator. Nothing changes for ordinary repository paths: the conditions only matter when the path also holds a secret name. Checked with the generated file loaded in Traefik 3.7.14: the climbing forms above get the shield's 404, and a real Gitea behind the same Traefik still takes a push and a clone over HTTPS and shows .env.example, .gitignore, .github, .ssh and .docker files in the web view, the raw view and the API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Internet scanners constantly ask every site for files that hold secrets:
/.env,/.env.local,/.git/config,/.aws/credentials,/.docker/config.json,/proc/self/environand so on. In 72 hours of one server's Traefik access log, about 5,000 of 92,000 requests were such probes, and all of them were forwarded to the deployed applications.With this change the panel creates a Traefik dynamic file,
probe-shield.yml, next to the other default files (middlewares.yml,dokploy.yml). It contains one router that answers these requests with404straight from Traefik. They never reach an application.A second benefit: if a deployed app serves its working directory by mistake, this rule stops
.envand.gitfrom leaking through it.Git services, and the other products listed below, keep working without any edit.
The rule and the priority
One HTTP router,
probe-shield:websecure(the HTTPS entry point of every default Traefik config in the repo and of the installer);Hostin the rule, so it covers every host;priority: 2147482647(MaxInt32 - 1000). Traefik reserves the top 1000 priorities for its own routers, so this is the highest priority a user router may take on any platform. The panel generates routers with the default priority (the rule length), so nothing it makes can beat this one;service: noop@internalplus anipAllowListmiddleware whose only allowed range is0.0.0.0/32withrejectStatusCode: 404. Traefik has no "return a status" middleware, so this is the known way to answer without a backend;tls: {}, explicit, so the entry point'scertResolveris not applied to a router that has no domain.The rule is built from five Go RE2 regexps (case-insensitive, no lookaround) joined with Traefik's rule operators:
SECRET matches, at any depth:
.env;.git,.aws,.ssh,.svn,.hg,.docker,.kube, as a whole segment;.git-credentials,.gitconfig,.DS_Store,.htpasswd,.htaccess,.npmrc,.netrc,.pgpass,.bash_history;proc/self/.It does not block every dot segment and does not touch
.php:/.well-known/,.gitignore,.github/,.gitlab-ci.yml,repo.git/info/refs,/environment,app.env.jsall pass.Traefik keeps
%2Fencoded in the path it matches and decodes%5Cto a backslash, so/static/..%2F..%2F.envwas not caught by a plain/.envpattern and reached the backend in my first test run. The separator in front of and behind each name therefore also accepts%2F,%5Cand a literal backslash. Traefik decodes%2e/%65(unreserved characters) and cleans literal..before matching, so/%2eenvand/a/../.envwere already caught.What is exempt, and why
A path that matches SECRET is still let through when it has one of these fixed route shapes. In each shape the dot name is a file the user asked a product to show, and the shape is a chain of words an ordinary site or a static file tree does not have, so none of them can be reached by
/.env,/src/.env,/raw/.env,/blob/.env,/media/.envor/api/.env:/{owner}/{repo}/(src|raw|render|commits|blame|media|tree-list|tree-view)/(branch|tag|commit)/{ref}/...,/{owner}/{repo}/(_edit|_new|_delete|_upload|_diffpatch|_preview)/{ref}/...,/{owner}/{repo}/(lastcommit|_cherrypick)/{sha}/.../api/v1/repos/{owner}/{repo}/(contents|contents-ext|raw|media|editorconfig)/.../{group}[/{subgroup}...]/{project}/-/(blob|raw|tree|blame|edit|new|update|preview|commits)/{ref}/..., Web IDE/-/ide/project/.../api/v4/projects/{id}/repository/files/{url-encoded path}(here%2Fis expected, so%2Fis allowed)/remote.php/(dav|webdav)/...,/public.php/(dav|webdav)/...Owner and group segments must not start with a dot (such names do not exist), so a dot name is never read as an owner.
Two guards stop an exempt prefix from becoming a way out of it. A path is not exempt when it contains any of
..,;,%3B,%5C,\,%25or%00anywhere, and, in every shape except the GitLab files API,%2F(in that API%2Fis the normal separator). Plain/../never gets this far: Traefik cleans it first.The first version of the guards looked only for an encoded slash or backslash. Review found that
/owner/repo/src/branch/main/..;/..;/..;/.envkept its exemption and reached the backend: Tomcat, Jetty and other servers that drop;...from a segment read..;/as.., so on such a backend the request ends at/.env...%3B/behaved the same way (Traefik keeps%3Bencoded), and so did%252e%252e/for a backend that decodes twice. With the wider guard all of these get the shield's 404 (see "Climbing out of an exempt prefix" below). The guard only matters for a path that also holds a secret name, so ordinary repository paths are unaffected.Not blocked at all, because the paths hold no dot-name segment: Git over HTTP for bare repositories (
/{owner}/{repo}.git/info/refs,git-upload-pack,git-receive-pack, LFS endpoints), repositories whose name starts with a dot (/{owner}/.github,/{owner}/.github.git/info/refs), and the panel's own Git routes.What an attacker gains from an exempt shape. Sending
/x/y/src/branch/z/.envto an app that is not a Git UI reaches that app with an odd path. It returns a file only if the app serves a file tree that really containsx/y/src/branch/z/.env, which is the same as saying "the app already exposes arbitrary files", and an attacker would have to guess every directory in the middle. The yardstick I used: an exemption must not let anyone fetch a file that a plain static file server or a misconfigured app with an exposed.envor.gitdirectory would hand out at a predictable path. That holds for every shape above because none of them is a path that appears in a working tree by itself.Decided against
/project/.git/info/refs?service=git-upload-pack,POST /project/.git/git-upload-pack,POST /project/.git/git-receive-pack, as served bygit-http-backendor cgit). Stays blocked.git-upload-packandgit-receive-packare RPC endpoints that a static server does not have, but a clone cannot start without theinfo/refsrequest, andinfo/refswith a?service=query is exactly what a plain file server answers with the file (the query is ignored). In a test with a plain static server serving a non-bare repository,/.git/info/refs?service=git-upload-packreturned the ref list; it is the first file a dumper asks for. A header or query condition would not help, since the attacker controls both. Hosting git this way behind Traefik is rare (bare repositories atrepo.gitare the normal shape and are not blocked); that host takes the per-host exclusion below.branch|tag|commitword (/{owner}/{repo}/src/{ref}/...,/raw/{ref}/...). The only difference from/x/y/src/z/.envon a random app is the wordsrc, which is also a real directory in many projects, so this shape is not distinguishable from a served working tree. Stays blocked; per-host exclusion./bucket/project/.env). There is no safe path shape, and a header-based exemption would open/.envon every host. Such a host takes the per-host exclusion. The generated file's header says so.Escape hatches
Both are documented in the comment at the top of the generated file.
&& !Host(s3.example.com)to the end of the rule (the rule is a plain YAML scalar, so no quoting is needed). Add more hosts with&& !Host(...)again.# off. The panel's save API rejects an empty string (min(1)), so the comment line is the way from the UI; an empty file also works for Traefik. Do not delete the file: it is created again on the next panel start.What it deliberately does not do
/index.php,/wp-login.php,/credentials.json,/secrets.json): those are valid paths of real apps.websecureonly.Install versus update
createDefaultMiddlewaresalready runs on every panel start inapps/dokploy/server/server.ts, and the newcreateDefaultProbeShieldruns right after it. The first start after the update creates the file and Traefik's file provider picks it up without a restart.traefik.yml,middlewares.ymlanddokploy.ymlitself; the shield is created by the panel when it first starts. It is also created byapps/dokploy/setup.ts.Remote servers
The same file is created on a remote server by the server setup script. The step sits right after the middlewares step in
packages/server/src/setup/server-setup.ts(buildProbeShieldInstallStep). It writes the file withsudo teewhen not root, only if the file is missing, and prints a warning instead of failing the setup if the write does not work. An existing remote server gets the file the next time "Setup Server" is run for it; nothing changes on it before that.Tested on a separate machine over SSH
This was a separate Linux machine (Ubuntu 24.04, arm64) created with OrbStack on the same Mac and reached over SSH with a temporary key pair. It was not a rented server, and no server of the project was touched. The machine, the key pair and everything else created for the test were deleted afterwards.
The harness does what
installRequirementsdoes: anssh2Client,connect({ host, port, username, privateKey }), thenclient.exec(<script text>). The script text is the output ofdefaultCommand(false)from this branch, unedited (26145 characters).Run 1, as root, on a fresh machine. The script ran to the end (all 14 steps, stream closed with exit code 0): packages, RClone, Docker 29.8.2 from the install script, Docker Swarm, the
dokploy-networkoverlay network, directories,traefik.yml,middlewares.yml, the probe shield step, thedokploy-traefikcontainer, Nixpacks, Buildpacks, Railpack. Nothing had to be prepared by hand, and the script did not stop anywhere./etc/dokploy/traefik/dynamic/probe-shield.yml09dcda5b3cc7...6741f0a; the same bytes as the file used for the local Traefik and Gitea tests, and asgetDefaultProbeShield()returnsprobe-shieldrouter (priority 2147482647,websecure,noop@internal,tls: {}) and theprobe-shield-denymiddleware, exactly as in this PRdokploy-traefik,traefik:v3.7.14, runningdocker portandHostConfig.PortBindingsshow only443/tcpand443/udp(IPv4 and IPv6). Nothing listens on 80 (ss).traefik.ymlstill defines awebentry point on:80, as before, but the script does not publish it. From the Mac, port 443 answers and ports 80, 81 and 8080 give no answer (an unpublished port 81 behaves like 80)probe-shield@fileisenabledA small test service (
traefik/whoami, a swarm service ondokploy-network) behind a normal router forapp.example.com, and requests through that Traefik on 443:app.example.com/,/some/page,/.well-known/security.txt,/environment,/user/repo.git/info/refsapp.example.com/.env,/.env.local,/api/.env,/.git/config,/.git/HEAD,/project/.git/config,/.aws/credentials,/.docker/config.json,/proc/self/environ,/src/.env,/static/..%2F..%2F.envNot Found(the shield; the test service was not reached)/404 page not found(Traefik's own)/.env/200,/.env/.git/config/api/.env404Run 2, as root, with an edited file. I appended the documented host exclusion to the rule (
&& !Host(app.example.com)), checked that Traefik reloaded it (/.envand/.git/configonapp.example.comnow reached the test service, other hosts still got the shield's 404), then ran the same script again overssh2. It finished with exit code 0 and printedProbe shield config already exists. The file was byte-identical to my edited copy afterwards (same sha256,cmpidentical, no extra or backup files), the container was not recreated (uptime kept counting), the published ports were unchanged and the behaviour was unchanged.Run 3, as a non-root user with sudo, after the file was deleted. This is what an existing server looks like before the change: with the file gone,
/.envonapp.example.comreached the test service (200). The script ran as that user (the setup banner says it is running with sudo privileges), exit code 0, and the file was back with the same sha256 as in run 1. Traefik picked it up without a restart and/.envand/.git/configon every host were 404 again.Repeated with the final rule. The three runs above used the rule before the wider guard (file of 2841 bytes). After that change the test was repeated on a new fresh machine of the same kind (Ubuntu 24.04, arm64, created with OrbStack, reached through OrbStack's SSH endpoint as root; deleted afterwards). The whole
defaultCommand(false)text from the final commit (26288 characters) was sent as one SSH command, as the panel does.probe-shield.yml832b376d7c1ce215..., the same asgetDefaultProbeShield()returns for the final committraefik:v3.7.14running, 0 error lines in its log443/tcpand443/udp; nothing on 80/,/some/page,/.well-known/security.txt,/environment,/user/repo.git/info/refs,/o/r/src/branch/main/.env.example,/api/v1/repos/o/r/contents/.env.example,/remote.php/dav/files/u/p/.env/.env,/.env.local,/api/.env,/.git/config,/.git/HEAD,/project/.git/config,/.aws/credentials,/proc/self/environ,/src/.env,/static/..%2F..%2F.env,/o/r/src/branch/main/..;/..;/..;/..;/.env,/o/r/src/branch/main/..%3B/..%3B/.env,/o/r/src/branch/main/%252e%252e/%252e%252e/.env/and/.env&& !Host(...)to the ruleProbe shield config already exists, file sha256 unchanged, container not restarted, ports unchanged;/.envon the excluded host reached the service and stayed 404 on another hostNot tested on the machine: the panel's own "Setup Server" button (the same script text was sent with the same
ssh2calls instead), a machine with a real public address, and a server with Docker already running other services.Panel routes that Git providers call
The panel's routes under
pages/api(deploy webhooks/api/deploy/{token},/api/deploy/compose/{token},/api/deploy/github, the provider routes/api/providers/{github,gitlab,gitea}/..., trpc, auth, stripe, health) have no dot segment. A unit test lists every file underpages/api, turns it into a URL and asserts that the shield does not touch any of them, so a future route that would match fails the test.Access log
Traefik does not write access log lines for requests answered by an internal service unless
accessLog.addInternals: trueis set (the panel's Requests toggle does not set it). So by default blocked probes disappear from the access log and from the panel's Requests page. WithaddInternals: truethe line showsRouterName: probe-shield@file,DownstreamStatus: 404,OriginStatus: 0and noServiceName/ServiceAddr. I left the toggle unchanged.How it was verified
All against
traefik:v3.7.14(the version the repo pins), using the file produced bygetDefaultProbeShield(), with only a high local port published. Everything was removed afterwards.Whoami backend (an echo server, to see whether a request reaches the backend)
/.env,/.ENV,/api/.env.local,/.env/public/.env,/.git/config,/.aws/credentials,/proc/self/environ,/.DS_Store,/.docker/config.json,/config/.env/static/..%2F..%2F.env,/uploads/..%2F..%2F.env,/static/..%2F..%2F..%2F..%2Fproc/self/environ,/%2eenv/,/.well-known/security.txt,/environment,/api/envelope,/user/repo/src/branch/main/.gitignore,/assets/app.env.js,/git/config,/user/repo.git/info/refs/index.php,/wp-login.php,/credentials.json,/secrets.json/prio/okon a host router withpriority: 5000/prio/.envon the same host and routerThe backend's own request log had 0 lines for the blocked requests.
A real Gitea behind Traefik
gitea/gitea:latest(version string28.1.0, sqlite, SSH off) as a normal router on its own host, with the shield file loaded. A repository was created, and its files (.env.example,.gitignore,.github/workflows/ci.yml,.ssh/config,.docker/config.json) were added through the API calls below..env.example,.gitignore,.github/workflows/ci.yml,.ssh,.ssh/config,.docker,.docker/config.json(/src/branch/main/...)/raw/branch/main/.env.example,/raw/branch/main/.ssh/config,/media/branch/main/.env.example/blame/branch/main/.env.example,/commits/branch/main/.env.example/tree-view/branch/main/.ssh/src/commit/{sha}/.gitignore,/src/tag/v1/.github/workflows/ci.yml,/src/branch/feature/x/.env.example,/raw/commit/{sha}/...,/commits/tag/v1/...,/lastcommit/{sha}/.sshGET /_edit/main/.env.example,/_new/main/.ssh,/_delete/main/.env.example,/_upload/main/.sshPOST /_new/main/.ssh/creating.ssh/known_hosts_N, then its raw viewPOST /api/v1/repos/.../contents/{.env.example,.gitignore,.github/workflows/ci.yml,.ssh/config,.docker/config.json}(used to create the files)GET .../contents/.env.example?ref=main,.../contents/.ssh,.../contents/.ssh/config,.../raw/.env.example,.../media/.env.example,.../contents-ext/.env.exampleinfo/refs?service=git-upload-packforproj.gitand for a repository named.github(.github.git), page of a repository named.githubgit cloneover HTTPS, thengit pushof a commit adding.env.example.N,.npmrc.N,.kube/config.N, then a fresh clone shows the commitOn the same Gitea host, the shield still answers with its own 404 (9-byte body "Not Found", no content type; Gitea's own 404 is an HTML page):
/.env,/.env.local,/.git/config,/.git/HEAD,/project/.git/config,/{owner}/{repo}/.git/config,/api/.env,/src/.env,/raw/.env,/blob/.env,/media/.env,/.aws/credentials,/.aws/config,/.docker/config.json,/proc/self/environ,/.DS_Store,/api/v1/.env,/{owner}/.env,/{owner}/{repo}/.env,/{owner}/{repo}/src/.env,/{owner}/{repo}/raw/.env,/{owner}/{repo}/blob/main/.env,/{owner}/{repo}/src/main/.env,/{owner}/{repo}/src/branch/.env, and the traversal forms/{owner}/{repo}/src/branch/main/..%2F..%2F.env,.../raw/branch/main/..%2F..%2F..%2F.git%2Fconfig,.../src/branch/main/..%5C..%5C.env,.../src/branch/main/..\..\.env,.../src/branch/main/x%2F.env,/api/v1/repos/.../contents/..%2F..%2F.envare all 404 (the shield), and a literal../../../../.envclimb out of a Git route is cleaned by Traefik and also gets the shield's 404.WebDAV
A real WebDAV server (
hacdias/webdav, not Nextcloud itself) mounted at/remote.php/dav/files/user: MKCOL, PUT, GET ofproject2/.env,project2/.git/config, and PROPFIND of the folder all worked (201, 201, 200, 200, 207), DELETE 204./.envand/.git/configon the same host and.../project2/..%2F..%2F.envgot the shield's 404.GitLab
Not run: GitLab is too heavy to bring up here. Its URL shapes (
/-/blob,/-/raw,/-/tree/main/.github,/-/blame,/-/edit,/-/commits, nested groups, the Web IDE, and the files API with%2Fand%2E) were sent to the echo backend through the real Traefik: all reached it (200). The lookalikes/group/project/-/blob/.env,/-/blob/main/.env,.../files/..%2F..%2F.env,.../files/a%2F..%2F.git%2Fconfigand/api/v4/projects/5/repository/tree/.envgot the shield's 404. The shapes were checked against GitLab's documented routes, not against a running GitLab.A static file server, for the decision on
.git/info/refsA plain static server (busybox httpd) serving a directory that contains
.envand a real.gitaftergit update-server-info: with the shield/.env,/.git/HEAD,/.git/info/refsand/.git/info/refs?service=git-upload-packare 404; without it they returnSECRET_TOKEN=...,ref: refs/heads/mainand the ref list, query or no query.Climbing out of an exempt prefix
With the final rule, through Traefik 3.7.14 with an echo backend. Every line got the shield's 404 (9 bytes); with the first version of the guards the four lines marked * reached the backend with status 200.
The Gitea run was repeated with the final rule as well (Gitea 28.1.0): a push, a clone and a second push over HTTPS worked; the web view of
.env.example,.gitignore,.github/workflows/ci.yml,.ssh,.ssh/config,.docker/config.jsonandsrc/.env.sample, the raw view, blame, file history, a view by commit, the APIcontentsandrawcalls andinfo/refsall answered 200;/.env,/.git/config,/.git/HEAD,/owner/repo/.git/config,/owner/repo/.env,/api/.env,/src/.env,/raw/.env,/owner/repo/src/.env,/owner/repo/src/branch/.env, the..;/and..%2Fforms,/.aws/credentialsand/proc/self/environon the same host got the shield's 404.Other checks
certResolverand a host router that inherits the entry point's resolver each made Traefik ask for their own domain, and nothing was requested for the shield or for the Gitea host.X-Forwarded-For: 0.0.0.0does not change the answer; POST and HEAD give 404 too./.envthen reaches the backend. Host exclusion: with&& !Host(...)appended, that host's.env.exampleand.git/configreach the backend and every other host is still shielded.EntryPoint doesn't exist, other routers unaffected). All default configs definewebsecure.apps/dokploy/__test__/traefik/probe-shield.test.ts, 195 cases): the generated file parses and has the expected router, priority, rule, middleware and header; the five regexps are read back out of the generated rule and evaluated against about 160 blocked and allowed paths (every product shape, lookalikes, traversal, each bare wordsrc|raw|blob|media|dav|api|-after each dot name); every route underpages/api; create-only-when-missing; the remote shell step run in a real bash; the step order in the server setup script.biome checkandtsc --noEmitpass inpackages/serverandapps/dokploy.Not verified
git-http-backendwas attempted. Only the bare-repository shape was cloned, from a real Gitea.ssh2directly instead of pressing "Setup Server" in the panel./%252eenvorx%252f.envare not decoded by Traefik and are not matched by the secret-name pattern; an app would have to decode twice to be affected. Inside an exempt shape a%25cancels the exemption, so double encoding cannot be used to climb out of one.🤖 Generated with Claude Code