Skip to content

[Serve] Prevent symlink escapes outside the selected output root #692

Description

@cssbruno

Priority

High — local file disclosure and path-containment correctness

Context

gowdk serve, the static development handler, and the live-reload file handler serve generated files directly from a user-selected output directory.

The current path flow in cmd/gowdk/serve.go is effectively:

rel := strings.TrimPrefix(path.Clean("/"+candidate), "/")
filePath := filepath.Join(root, filepath.FromSlash(rel))
relative, err := filepath.Rel(root, filePath)
// reject lexical ../ escape
info, err := os.Stat(filePath)
// later: http.ServeFile(..., filePath)

The filepath.Rel check proves only lexical containment. os.Stat and http.ServeFile follow filesystem links. An intermediate directory symlink, a leaf symlink, or a Windows reparse-point/junction inside the selected output root can therefore resolve to a file outside that root.

The default address is loopback, but --addr allows the server to bind to other interfaces. A generated output directory may also contain copied or user-created assets that the caller did not expect to expose outside the declared root.

Generated apps that serve an embedded fs.FS are not the primary concern here; this issue is about disk-backed serving paths.

Problem

A path such as:

dist/site/public-link -> /home/user/private

can remain lexically inside dist/site while resolving outside it. A request for /public-link/file.txt may therefore expose /home/user/private/file.txt.

Related failure modes include:

  • a leaf symlink such as dist/site/config.json -> ../../config.json;
  • an intermediate symlinked directory;
  • Windows directory junctions or other reparse points;
  • a link being replaced between validation and open, creating a time-of-check/time-of-use race;
  • inconsistent behavior between gowdk serve, gowdk dev, and gowdk preview --hot when they share the disk-backed handler.

This should fail closed even when the output directory is considered developer-owned. The server contract says it serves the selected directory, not arbitrary files reachable through links from that directory.

Goal

Guarantee that every disk-backed file opened by GOWDK's serving commands is a regular file contained by the selected, resolved output root according to a documented symlink policy.

Proposed direction

1. Centralize rooted file opening

Introduce one helper/package used by all disk-backed serving paths, for example:

type RootedFiles struct {
    // unexported resolved root and policy
}

func OpenRooted(root string, requestPath string) (fs.File, fs.FileInfo, error)

The helper should own:

  • URL-path normalization;
  • directory-index candidate selection;
  • sensitive generated-file policy;
  • root containment;
  • symlink/reparse-point handling;
  • regular-file validation;
  • consistent not-found versus internal-error classification.

Do not leave each server mode to reproduce its own lexical check.

2. Reject filesystem links by default

The safest default for generated output is to reject any candidate whose path contains a symlink, junction, or equivalent filesystem indirection.

On platforms where race-resistant APIs are available, open path components relative to an already-open root directory and use no-follow semantics. On other platforms, combine Lstat/resolved-root validation with the strongest available checks and document residual platform limitations.

A simple filepath.EvalSymlinks check alone improves containment but does not eliminate replacement races. The implementation should prefer handle-relative/open-relative APIs where practical rather than claiming race resistance that it does not provide.

3. Make any link-following behavior explicit

If legitimate symlinked generated assets need support later, add an explicit policy such as --follow-symlinks rather than silently following them. Even in that mode, the final resolved path must remain inside the resolved root.

No link should ever permit an escape outside the selected root.

4. Preserve normal URL behavior

The fix should retain:

  • / and trailing-slash index resolution;
  • extensionless route lookup;
  • GET/HEAD-only serving;
  • live-reload HTML injection;
  • generated endpoint guidance for unsupported methods;
  • 404 behavior for unavailable files;
  • Windows and Unix path support.

Unsafe or escaping paths should return a generic 404 rather than disclose host filesystem structure.

5. Cover all disk-backed entrypoints

Audit and migrate at least:

  • gowdk serve;
  • static gowdk dev serving;
  • gowdk preview and preview --hot;
  • live-reload disk file reads;
  • any testkit or future disk-output server that reuses outputFilePath semantics.

Security considerations

  • Error responses and logs must not reveal the resolved external target path to remote clients.
  • Root resolution should happen once at startup where possible, while per-request checks should use that stable root identity.
  • The implementation must consider Windows junctions/reparse points, not only Unix symbolic links.
  • The server should not trust a lexical filepath.Rel result as proof of filesystem containment.
  • Tests should avoid requiring elevated symlink privileges where Windows policy prevents ordinary symlink creation; junction/reparse behavior can use platform-specific fixtures or focused unit boundaries.

Test plan

Add platform-neutral and platform-specific tests covering:

  • normal file and directory-index serving;
  • lexical .. attempts;
  • URL-encoded traversal attempts after normal HTTP URL parsing;
  • leaf symlink to a file outside the root;
  • intermediate symlinked directory outside the root;
  • symlink resolving to a file still inside the root, according to the selected default policy;
  • broken symlink;
  • link loop;
  • replacement of a checked path before open where a race-resistant abstraction can be tested;
  • non-regular files such as sockets, devices, and named pipes;
  • Windows junction/reparse-point containment;
  • gowdk serve, dev static serving, and live-reload behavior using the same rooted opener;
  • generic client-facing 404 without external target disclosure.

Include a regression test demonstrating that a request cannot retrieve a sentinel file created outside the served root through a link inside the root.

Acceptance criteria

  • Disk-backed serving never opens a file whose final filesystem target is outside the selected output root.
  • Symlinks/junctions/reparse points have one explicit, documented default policy.
  • The implementation does not rely solely on lexical filepath.Rel containment.
  • All GOWDK disk-backed server modes use the same rooted file-opening abstraction.
  • Unsafe paths produce a generic not-found response and do not disclose host paths.
  • Directory-index, extensionless-route, GET/HEAD, and live-reload behavior remain intact.
  • Unix and Windows containment behavior is covered by tests.
  • A regression test proves an outside-root sentinel cannot be served through a link.
  • Documentation explains the symlink policy and the risk of binding local preview servers to public interfaces.

Non-goals

  • Turning gowdk serve into a production web server.
  • Serving arbitrary source directories outside generated output.
  • Adding a general virtual filesystem or mount system.
  • Changing embedded generated-app file serving, except where shared policy code is intentionally reused.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions