Skip to content

feat(traefik): answer requests for secret files with 404 in Traefik - #67

Merged
agentHits merged 4 commits into
AgentHits-Devfrom
feat/traefik-probe-shield
Oct 13, 2026
Merged

agentHits merged 4 commits into
AgentHits-Devfrom
feat/traefik-probe-shield

Conversation

@agentHits

@agentHits agentHits commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

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/environ and 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 with 404 straight from Traefik. They never reach an application.

A second benefit: if a deployed app serves its working directory by mistake, this rule stops .env and .git from 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:

  • entry point websecure (the HTTPS entry point of every default Traefik config in the repo and of the installer);
  • no Host in 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@internal plus an ipAllowList middleware whose only allowed range is 0.0.0.0/32 with rejectStatusCode: 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's certResolver is 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:

PathRegexp(SECRET)
  && (!PathRegexp(GIT_AND_WEBDAV_ROUTES) || PathRegexp(ENCODED_SEPARATOR))
  && (!PathRegexp(GITLAB_FILES_API)      || PathRegexp(TRAVERSAL))

SECRET matches, at any depth:

  • a path segment that starts with .env;
  • the directories .git, .aws, .ssh, .svn, .hg, .docker, .kube, as a whole segment;
  • the files .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.js all pass.

Traefik keeps %2F encoded in the path it matches and decodes %5C to a backslash, so /static/..%2F..%2F.env was not caught by a plain /.env pattern and reached the backend in my first test run. The separator in front of and behind each name therefore also accepts %2F, %5C and a literal backslash. Traefik decodes %2e/%65 (unreserved characters) and cleans literal .. before matching, so /%2eenv and /a/../.env were 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/.env or /api/.env:

Product Exempt shapes (every one needs a non-empty rest after it)
Gitea / Forgejo web UI /{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}/...
Gitea / Forgejo API /api/v1/repos/{owner}/{repo}/(contents|contents-ext|raw|media|editorconfig)/...
GitLab web UI /{group}[/{subgroup}...]/{project}/-/(blob|raw|tree|blame|edit|new|update|preview|commits)/{ref}/..., Web IDE /-/ide/project/...
GitLab API /api/v4/projects/{id}/repository/files/{url-encoded path} (here %2F is expected, so %2F is allowed)
Nextcloud / ownCloud WebDAV /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, \, %25 or %00 anywhere, and, in every shape except the GitLab files API, %2F (in that API %2F is 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/..;/..;/..;/.env kept 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 %3B encoded), 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/.env to 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 contains x/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 .env or .git directory 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

  • Git smart HTTP on a repository with a working tree (/project/.git/info/refs?service=git-upload-pack, POST /project/.git/git-upload-pack, POST /project/.git/git-receive-pack, as served by git-http-backend or cgit). Stays blocked. git-upload-pack and git-receive-pack are RPC endpoints that a static server does not have, but a clone cannot start without the info/refs request, and info/refs with 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-pack returned 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 at repo.git are the normal shape and are not blocked); that host takes the per-host exclusion below.
  • Gogs and old Gitea URLs without a branch|tag|commit word (/{owner}/{repo}/src/{ref}/..., /raw/{ref}/...). The only difference from /x/y/src/z/.env on a random app is the word src, 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.
  • S3-compatible storage (object keys are arbitrary, e.g. /bucket/project/.env). There is no safe path shape, and a header-based exemption would open /.env on 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.

  • Skip one host: edit the file in the panel under Traefik File System and append && !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.
  • Turn it off: replace the whole content of the file with a comment line such as # 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

  • It does not drop packets, rate-limit or ban addresses. It only answers 404.
  • It does not block what cannot be blocked for everyone (/index.php, /wp-login.php, /credentials.json, /secrets.json): those are valid paths of real apps.
  • It adds no port 80 and no HTTP challenge. Certificates still renew over 443 only (tlsChallenge). The router is on websecure only.
  • No database column, migration or settings UI. The file is the switch.

Install versus update

  • The file is created only when it does not exist (written with an exclusive open), so an edited file is never overwritten.
  • Update of an existing installation: createDefaultMiddlewares already runs on every panel start in apps/dokploy/server/server.ts, and the new createDefaultProbeShield runs right after it. The first start after the update creates the file and Traefik's file provider picks it up without a restart.
  • Fresh install: the installer script writes traefik.yml, middlewares.yml and dokploy.yml itself; the shield is created by the panel when it first starts. It is also created by apps/dokploy/setup.ts.
  • A failure to write the file is logged and never stops the panel.

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 with sudo tee when 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 installRequirements does: an ssh2 Client, connect({ host, port, username, privateKey }), then client.exec(<script text>). The script text is the output of defaultCommand(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-network overlay network, directories, traefik.yml, middlewares.yml, the probe shield step, the dokploy-traefik container, Nixpacks, Buildpacks, Railpack. Nothing had to be prepared by hand, and the script did not stop anywhere.

Check Result
/etc/dokploy/traefik/dynamic/probe-shield.yml exists, mode 644, 2841 bytes, sha256 09dcda5b3cc7...6741f0a; the same bytes as the file used for the local Traefik and Gitea tests, and as getDefaultProbeShield() returns
Content the header, then the probe-shield router (priority 2147482647, websecure, noop@internal, tls: {}) and the probe-shield-deny middleware, exactly as in this PR
Traefik container dokploy-traefik, traefik:v3.7.14, running
Published ports docker port and HostConfig.PortBindings show only 443/tcp and 443/udp (IPv4 and IPv6). Nothing listens on 80 (ss). traefik.yml still defines a web entry 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)
Traefik log 0 lines at the default level, so no error about the file. Through Traefik's API (read inside the container) the router probe-shield@file is enabled

A small test service (traefik/whoami, a swarm service on dokploy-network) behind a normal router for app.example.com, and requests through that Traefik on 443:

Request Result
app.example.com /, /some/page, /.well-known/security.txt, /environment, /user/repo.git/info/refs 200 from the test service
app.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.env 404, 9 bytes, Not Found (the shield; the test service was not reached)
a host with no router, / 404, 19 bytes, 404 page not found (Traefik's own)
the same host, /.env 404, 9 bytes (the shield answers on every host)
the same requests from the Mac to the machine on 443 / 200, /.env /.git/config /api/.env 404

Run 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 (/.env and /.git/config on app.example.com now reached the test service, other hosts still got the shield's 404), then ran the same script again over ssh2. It finished with exit code 0 and printed Probe shield config already exists. The file was byte-identical to my edited copy afterwards (same sha256, cmp identical, 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, /.env on app.example.com reached 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 /.env and /.git/config on 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.

Check Result
Before the run no Docker, nothing listening on 80 or 443
Run 1 all 14 steps, exit code 0
probe-shield.yml mode 644, 2932 bytes, sha256 832b376d7c1ce215..., the same as getDefaultProbeShield() returns for the final commit
Traefik traefik:v3.7.14 running, 0 error lines in its log
Published and listening only 443/tcp and 443/udp; nothing on 80
Test service behind a normal router: /, /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 200 from the service
Same host: /.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 404, 9 bytes (the shield)
A host with no router: / and /.env 404, 19 bytes (Traefik's own) and 404, 9 bytes (the shield)
Run 2 after appending && !Host(...) to the rule exit code 0, Probe shield config already exists, file sha256 unchanged, container not restarted, ports unchanged; /.env on the excluded host reached the service and stayed 404 on another host

Not tested on the machine: the panel's own "Setup Server" button (the same script text was sent with the same ssh2 calls 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 under pages/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: true is 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. With addInternals: true the line shows RouterName: probe-shield@file, DownstreamStatus: 404, OriginStatus: 0 and no ServiceName/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 by getDefaultProbeShield(), with only a high local port published. Everything was removed afterwards.

Whoami backend (an echo server, to see whether a request reaches the backend)

Request Result
/.env, /.ENV, /api/.env.local, /.env/public/.env, /.git/config, /.aws/credentials, /proc/self/environ, /.DS_Store, /.docker/config.json, /config/.env 404, backend not reached
/static/..%2F..%2F.env, /uploads/..%2F..%2F.env, /static/..%2F..%2F..%2F..%2Fproc/self/environ, /%2eenv 404, backend not reached
/, /.well-known/security.txt, /environment, /api/envelope, /user/repo/src/branch/main/.gitignore, /assets/app.env.js, /git/config, /user/repo.git/info/refs 200, backend reached
/index.php, /wp-login.php, /credentials.json, /secrets.json 200, backend reached
/prio/ok on a host router with priority: 5000 200 (that router still works)
/prio/.env on the same host and router 404 (the shield wins)

The backend's own request log had 0 lines for the blocked requests.

A real Gitea behind Traefik

gitea/gitea:latest (version string 28.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.

Through Traefik Result
Web file view of .env.example, .gitignore, .github/workflows/ci.yml, .ssh, .ssh/config, .docker, .docker/config.json (/src/branch/main/...) 200 text/html (Gitea)
Raw view /raw/branch/main/.env.example, /raw/branch/main/.ssh/config, /media/branch/main/.env.example 200 text/plain
Blame and history /blame/branch/main/.env.example, /commits/branch/main/.env.example 200 text/html
Directory sidebar /tree-view/branch/main/.ssh 200 json
By commit, tag and slash branch: /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}/.ssh 200
Web editor with a logged-in session: GET /_edit/main/.env.example, /_new/main/.ssh, /_delete/main/.env.example, /_upload/main/.ssh 200 text/html
Web editor form: POST /_new/main/.ssh/ creating .ssh/known_hosts_N, then its raw view 200, 200
API: POST /api/v1/repos/.../contents/{.env.example,.gitignore,.github/workflows/ci.yml,.ssh/config,.docker/config.json} (used to create the files) 201 for each
API: GET .../contents/.env.example?ref=main, .../contents/.ssh, .../contents/.ssh/config, .../raw/.env.example, .../media/.env.example, .../contents-ext/.env.example 200
Git over HTTP: info/refs?service=git-upload-pack for proj.git and for a repository named .github (.github.git), page of a repository named .github 200
git clone over HTTPS, then git push of a commit adding .env.example.N, .npmrc.N, .kube/config.N, then a fresh clone shows the commit clone OK, push OK, 6 dotfiles in the new clone

On 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.env are all 404 (the shield), and a literal ../../../../.env climb 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 of project2/.env, project2/.git/config, and PROPFIND of the folder all worked (201, 201, 200, 200, 207), DELETE 204. /.env and /.git/config on the same host and .../project2/..%2F..%2F.env got 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 %2F and %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%2Fconfig and /api/v4/projects/5/repository/tree/.env got 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/refs

A plain static server (busybox httpd) serving a directory that contains .env and a real .git after git update-server-info: with the shield /.env, /.git/HEAD, /.git/info/refs and /.git/info/refs?service=git-upload-pack are 404; without it they return SECRET_TOKEN=..., ref: refs/heads/main and 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.

/o/r/src/branch/main/../../../../../.env
/o/r/src/branch/main/..%2F..%2F..%2F..%2F..%2F.env
/o/r/src/branch/main/..%5C..%5C.env
/o/r/src/branch/main/..;/..;/..;/..;/..;/.env          *
/o/r/src/branch/main/..;/..;/..;/..;/..;/.git/config   *
/o/r/src/branch/main/..%3B/..%3B/..%3B/..%3B/..%3B/.env *
/o/r/src/branch/main/%2e%2e/%2e%2e/%2e%2e/%2e%2e/%2e%2e/.env
/o/r/src/branch/main/%252e%252e/%252e%252e/.env
/o/r/src/branch/main/x;a=b/.env
/api/v4/projects/1/repository/files/..;/..;/..;/..;/..;/..;/.env *
/api/v4/projects/1/repository/files/..%2F..%2F.env
/remote.php/dav/..;/..;/.env

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.json and src/.env.sample, the raw view, blame, file history, a view by commit, the API contents and raw calls and info/refs all 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 ..%2F forms, /.aws/credentials and /proc/self/environ on the same host got the shield's 404.

Other checks

  • Certificates: with and without the shield file, the same certificate is served by SNI (one per host, Traefik's default certificate for unknown names).
  • ACME: a host router with certResolver and 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.0 does not change the answer; POST and HEAD give 404 too.
  • Off switch: an emptied file and a comment-only file both load without an error, and /.env then reaches the backend. Host exclusion: with && !Host(...) appended, that host's .env.example and .git/config reach the backend and every other host is still shielded.
  • A router naming a missing entry point is rejected by Traefik alone (EntryPoint doesn't exist, other routers unaffected). All default configs define websecure.
  • Unit tests (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 word src|raw|blob|media|dav|api|- after each dot name); every route under pages/api; create-only-when-missing; the remote shell step run in a real bash; the step order in the server setup script. biome check and tsc --noEmit pass in packages/server and apps/dokploy.

Not verified

  • GitLab itself, Forgejo, Nextcloud and ownCloud were not run; their shapes were tested with an echo backend (GitLab, Nextcloud) or inferred from Gitea (Forgejo). The WebDAV prefix was tested with a generic WebDAV server mounted at the same path.
  • Git smart HTTP on a repository with a working tree is blocked, so no clone through git-http-backend was attempted. Only the bare-repository shape was cloned, from a real Gitea.
  • The remote-server test used a local Linux machine, not a rented server, and called the setup script through ssh2 directly instead of pressing "Setup Server" in the panel.
  • HTTP/3 (UDP 443) was not exercised; routers are shared between transports.
  • Double-encoded names such as /%252eenv or x%252f.env are 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 %25 cancels the exemption, so double encoding cannot be used to climb out of one.
  • Behaviour on 32-bit Traefik builds was not run; the priority is below the documented 32-bit limit.

🤖 Generated with Claude Code

agentHits and others added 4 commits October 13, 2026 12:00
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>
@agentHits
agentHits merged commit 5389051 into AgentHits-Dev Oct 13, 2026
7 checks passed
@agentHits
agentHits deleted the feat/traefik-probe-shield branch October 13, 2026 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant