Skip to content

feat(ingress): add --file to serve a local directory - #1799

Open
hughcube wants to merge 6 commits into
cloudflare:masterfrom
hughcube:feature/serve-local-directory
Open

hughcube wants to merge 6 commits into
cloudflare:masterfrom
hughcube:feature/serve-local-directory

Conversation

@hughcube

Copy link
Copy Markdown

What

Adds a built-in, read-only file server origin, so a local directory can be served through a tunnel without starting a separate web server.

cloudflared tunnel --file ./public

The same origin is available from a configuration file:

ingress:
  - hostname: files.example.com
    service: file:/abs/path
  - service: http_status:404

Refs #611.

Why

Sharing a directory today means running a separate static server (python -m http.server, caddy file-server, ...) and pointing --url at it. #611 asks for the ngrok-equivalent file:// origin.

The change is small because cloudflared already hosts origins itself: helloWorld starts a listener and rewrites httpService.url. fileServer does exactly the same, with a hardened file handler instead of a hello page.

How

  • ingress/origin_file.go: new fileServer origin. On start() it listens on 127.0.0.1:0 and points the embedded httpService at it. Nothing is bound to a public interface, all traffic still arrives through the existing tunnel.
  • ingress/ingress.go: accept service: file:<dir> in configuration, and --file on the CLI via parseSingleOriginService.
  • cmd/cloudflared/tunnel/{cmd,quick_tunnel}.go: register --file, let it trigger a quick tunnel, and show the served directory in the startup banner.

Safety

Deliberately stricter than http.FileServer defaults:

  • GET/HEAD only, everything else 405
  • no directory listings
  • dotfiles and dot directories always 404
  • symlinks resolved with EvalSymlinks and required to stay inside the root
  • X-Content-Type-Options: nosniff
  • --file is mutually exclusive with --url and --unix-socket

ingress/origin_file_test.go covers raw and percent-encoded traversal, dotfiles, directory listings, symlink escape, method filtering and invalid roots.

Verified end to end on Windows: cloudflared tunnel --file <dir> returns 200 for a file and 404 for .env over a quick tunnel.

Deliberately not included

  • fileServer knobs in originRequest (index, deny list, cache headers, listing opt-in). Happy to add if the direction is accepted.
  • UNC path policy on Windows: \\\\server\\share is currently treated like any other path.

I understand this may be out of scope for cloudflared. If so, closing this without merge is fine, but the code is here if the direction ever changes.

Adds a built-in read-only file server origin, so a local directory can be
shared through a tunnel without running a separate web server:

    cloudflared tunnel --file ./public

The same origin is available from a configuration file:

    ingress:
      - hostname: files.example.com
        service: file:/abs/path

The origin never listens on a public interface: cloudflared starts an
in-process HTTP server on 127.0.0.1:0 and points the embedded httpService
at it, the same way --hello-world does.

Hardening, deliberately different from http.FileServer defaults:
- GET / HEAD only, everything else 405
- no directory listings (directories are 404)
- dotfiles and dot directories are always 404
- symlinks are resolved and must stay inside the root
- X-Content-Type-Options: nosniff
- --file is mutually exclusive with --url and --unix-socket

Tests cover traversal (raw and percent-encoded), dotfiles, directory
listings, symlink escape, method filtering and invalid roots.

Refs cloudflare#611
- reject path components with trailing dots or spaces: Windows strips them,
  so "/a.txt." used to alias "a.txt"
- set IdleTimeout on the in-process server so a slow client cannot pin a
  connection indefinitely
- move the filesystem checks from parsing to start(), so validating a
  configuration file no longer requires the directory to exist on the machine
  doing the validation
- print the resolved absolute directory in the quick tunnel banner instead of
  the raw flag value
- add tests for CLI flag parsing, the config service form, flag exclusivity
  and the trailing-dot alias
@hughcube
hughcube force-pushed the feature/serve-local-directory branch from 81f1b5d to 5be4cc2 Compare November 13, 2026 15:22
hughcube added 3 commits November 13, 2026 23:46
Red team review found that the segment checks validated the request path
while the filesystem resolved it differently:

- a backslash separates path components on Windows but is opaque to
  path.Clean, so "/%5c.env" collapsed into a single segment and bypassed
  both the dotfile rule and the ".." rule, serving .env and .git/config
  from a directory that was supposed to hide them
- Windows 8.3 short names ("GIT~1") aliased dot directories without any
  backslash involved
- reserved device names ("NUL") resolved successfully and are not regular
  files

Fixes:
- reject any request path containing a backslash outright
- factor the rules into safeRelativePath and apply it to the request path
  AND the resolved filesystem path, which catches short names and symlink
  indirection
- reject Windows reserved device names
- require a per-instance random header on the loopback listener, so other
  processes on the machine cannot read the directory by connecting to the
  port directly; RoundTrip injects it for traffic that came via the tunnel
- only serve regular files

Tests cover every vector above, plus a request without the loopback header.
Second round of adversarial review found that the first round of tests could
not actually fail: on Linux, deleting three of the four path defences left the
suite green, because the payloads 404 anyway when the file does not exist.

- move the path policy into safeRelativePath and test it directly, so every
  rule is pinned on every platform instead of relying on filesystem state
- make safeRelativePath itself reject backslashes, removing the platform
  dependency from the rule (the HTTP layer still rejects them early)
- add unit tests for reserved device names, which previously had no coverage
- --file is now rejected together with --hello-world and --bastion too; before
  this it silently won because its check came first
- call server.Close() on shutdown so keep-alive connections the shared
  transport may hold are torn down, and drop the write-only listener field
- document that only "/" maps to index.html, so "/sub/" is a 404 by design

Verified by mutation: deleting the backslash rule now fails TestSafeRelativePath.
filepath.Rel returns OS-separated paths, so rejecting every backslash also
rejected nested files on Windows. Split the policy: safeRequestPath rejects
backslashes for request-derived paths, safeRelativePath normalises and
validates filesystem-derived paths.
@hughcube
hughcube force-pushed the feature/serve-local-directory branch from 4f72281 to a87c24d Compare November 13, 2026 16:28
Adversarial review, round three, ran mutation tests over every layer of the
path policy. Two of them were provably dead and one comment was wrong about
why a check exists:

- the explicit backslash block in ServeHTTP tested the same predicate as
  safeRequestPath (path.Clean never produces or removes a backslash), so it
  is removed; the policy function keeps the rule
- isWithin was fully subsumed by the resolved-path check, which rejects both
  a ".." escape and a symlink pointing outside the root; removed, and the
  remaining block now says that it is the containment check
- the comment claimed the backslash rule stopped "/%5c.env" from reaching
  ".env". It does not: safeRelativePath maps the backslash through
  filepath.ToSlash, so that payload is rejected by the empty-segment rule.
  What the backslash rule actually buys is a single URL per file. Comment
  corrected rather than the code changed.

New tests, both verified to fail when the defence they cover is removed:

- a symlink inside the root pointing at a dotfile, which only the resolved
  check catches
- a request through RoundTrip versus a direct hit on the loopback port;
  without this, deleting RoundTrip would keep the suite green while every
  real request returns 403
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