Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@ jobs:
}
Write-Output "Verified ${testName}: $passes passes, $skips skips"
}
- name: Exercise static cover path policy on Windows
shell: pwsh
run: |
$staticTests = @('TestStaticHandlerServesGETAndHEAD', 'TestStaticHandlerUsesStandardErrors', 'TestStaticHandlerValidatesDirectory', 'TestStaticFileSystemPathPolicy')
$selection = '^(' + ($staticTests -join '|') + ')$'
$testOutput = & go test ./internal/cover -run $selection -count=1 -timeout=30s -json
$testExit = $LASTEXITCODE
$testOutput | Write-Output
if ($testExit -ne 0) { exit $testExit }
$testEvents = @($testOutput | ForEach-Object { $_ | ConvertFrom-Json -ErrorAction Stop })
foreach ($testName in $staticTests) {
$passes = @($testEvents | Where-Object { $_.Test -eq $testName -and $_.Action -eq 'pass' }).Count
$skips = @($testEvents | Where-Object { $_.Test -eq $testName -and $_.Action -eq 'skip' }).Count
if ($passes -ne 1 -or $skips -ne 0) {
throw "$testName requires exactly 1 pass and 0 skips; got $passes passes and $skips skips"
}
Write-Output "Verified ${testName}: $passes passes, $skips skips"
}

race:
name: Race detector
Expand Down
7 changes: 7 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,13 @@ sudo -u autocar /usr/local/bin/autocar server \
--token-file /etc/autocar/relay-token
```

Use a dedicated public-only site tree. Source builds after v1.0.1 reject static
links that escape `--cover-root`, absolute symbolic links, and dot-prefixed
paths; root-level `/.well-known/` remains available. Replace absolute asset
links before upgrading. These checks do not isolate bind mounts, hard links,
or public aliases to private content, so never include credentials in the
site tree. See [static-directory boundaries](WEB_COVER.md#static-directory).

For a fixed authorized origin, replace `--cover-root` with, for example,
`--cover-upstream https://origin.example.net`. The two flags are mutually
exclusive and exactly one is required. The reverse proxy fixes the upstream
Expand Down
22 changes: 22 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,28 @@ The static handler serves `GET` and `HEAD`. Other methods receive the same
ordinary `405 Method Not Allowed` behavior whether they came from a random web
client or from an invalid tunnel probe.

Source builds after v1.0.1 confine each static request to `--cover-root` using
Go's [traversal-resistant file API](https://go.dev/blog/osroot). Relative symbolic
links that stay inside the root continue to work; links outside the root and
absolute symbolic links (even those pointing back inside it) are not served.
Dot-prefixed path components such as `.env` and `.git` return ordinary `404`
responses and are omitted from directory listings. This policy applies to
normalized, decoded URL paths; backslash paths are rejected, and Windows
also rejects colon paths to prevent alternate-data-stream access. The root-level
`/.well-known/` directory remains public, including ACME challenge files, but
dot-prefixed entries beneath it are still hidden. Normal index pages,
directory redirects/listings, `HEAD`, and byte ranges retain standard HTTP
file-server behavior.

The root handle is opened and closed per request, so deploying a replacement
site at the configured directory takes effect on subsequent requests without
leaving a long-lived handler-owned descriptor. This is not a filesystem sandbox:
mount points, hard links and public aliases to hidden content are not separated
by this policy. Keep the site tree public-only and the configured directory and
its parent under trusted control;
do not mount credentials or device files inside it. On upgrade, replace any
absolute site links with confined relative links or ordinary copied files.

Source builds after v1.0.1 send `OPTIONS *` through the configured cover handler
on HTTP/1.1, HTTP/2, and HTTP/3, instead of letting the TCP server return a
separate automatic response. Static cover therefore returns its ordinary `405`
Expand Down
35 changes: 0 additions & 35 deletions internal/cover/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ import (
"net/http/httputil"
"net/textproto"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
)
Expand All @@ -30,39 +28,6 @@ var hopByHopHeaders = [...]string{
"Upgrade",
}

// NewStaticHandler returns a handler rooted at directory. Only GET and HEAD
// are accepted; all other methods receive a normal HTTP 405 response.
func NewStaticHandler(directory string) (http.Handler, error) {
if strings.TrimSpace(directory) == "" {
return nil, errors.New("static directory is required")
}
root, err := filepath.Abs(directory)
if err != nil {
return nil, errors.New("resolve static directory")
}
info, err := os.Stat(root)
if err != nil {
return nil, errors.New("open static directory")
}
if !info.IsDir() {
return nil, errors.New("static path is not a directory")
}
return &staticHandler{files: http.FileServer(http.Dir(root))}, nil
}

type staticHandler struct {
files http.Handler
}

func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
h.files.ServeHTTP(w, r)
}

// NewReverseProxyHandler returns a reverse proxy that can dial only origin.
// The requester controls the path, query, and ordinary end-to-end headers, but
// never the upstream scheme, authority, or Host header.
Expand Down
157 changes: 157 additions & 0 deletions internal/cover/static.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package cover

import (
"errors"
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"runtime"
"strings"
)

// NewStaticHandler returns a handler rooted at directory. Only GET and HEAD
// are accepted; all other methods receive a normal HTTP 405 response.
// Each request opens its own root, so the handler owns no persistent handle.
// Root confinement does not exclude mounts, hard links, or public aliases to
// hidden files inside the root. The configured directory remains operator-owned.
func NewStaticHandler(directory string) (http.Handler, error) {
if strings.TrimSpace(directory) == "" {
return nil, errors.New("static directory is required")
}
root, err := filepath.Abs(directory)
if err != nil {
return nil, errors.New("resolve static directory")
}
info, err := os.Stat(root)
if err != nil {
return nil, errors.New("open static directory")
}
if !info.IsDir() {
return nil, errors.New("static path is not a directory")
}
// Validate actual access without retaining a handle through configuration
// checks, listener failures, or the shared H2/H3 cover lifecycle.
validationRoot, err := os.OpenRoot(root)
if err != nil {
return nil, errors.New("open static directory")
}
if err := validationRoot.Close(); err != nil {
return nil, errors.New("close static directory")
}
return &staticHandler{directory: root}, nil
}

type staticHandler struct {
directory string
}

func (h *staticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
if !staticPathAllowed(r.URL.Path) {
http.NotFound(w, r)
return
}
root, err := os.OpenRoot(h.directory)
if err != nil {
http.NotFound(w, r)
return
}
defer root.Close()
// FileServerFS keeps ordinary redirects, index lookup, HEAD, and Range.
// All files opened for this request use the same root and are closed by
// FileServer before this request's root handle is released.
http.FileServerFS(staticFileSystem{base: root.FS()}).ServeHTTP(w, r)
}

// staticPathAllowed uses the decoded, normalized URL path, just as FileServer
// does. The well-known exception belongs only to the root directory. Backslash
// is never a public path separator; Windows alternate streams are also denied.
func staticPathAllowed(name string) bool {
if strings.ContainsRune(name, '\\') || (runtime.GOOS == "windows" && strings.ContainsRune(name, ':')) {
return false
}
cleaned := strings.TrimPrefix(path.Clean("/"+name), "/")
if cleaned == "" {
return true
}
for index, component := range strings.Split(cleaned, "/") {
if !staticComponentAllowed(component, index == 0) {
return false
}
}
return true
}

func staticComponentAllowed(name string, root bool) bool {
if strings.ContainsRune(name, '\\') || (runtime.GOOS == "windows" && strings.ContainsRune(name, ':')) {
return false
}
return !strings.HasPrefix(name, ".") || (root && name == ".well-known")
}

type staticFileSystem struct {
base fs.FS
}

func (s staticFileSystem) Open(name string) (fs.File, error) {
if !fs.ValidPath(name) || !staticPathAllowed(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
file, err := s.base.Open(name)
if err != nil {
// Keep denied links, unavailable files, and platform-specific unsafe
// names indistinguishable, without exposing local paths in errors.
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
return &staticFile{File: file, directory: name}, nil
}

type staticFile struct {
fs.File
directory string
}

func (f *staticFile) Seek(offset int64, whence int) (int64, error) {
seeker, ok := f.File.(io.Seeker)
if !ok {
return 0, fs.ErrInvalid
}
return seeker.Seek(offset, whence)
}

func (f *staticFile) ReadDir(n int) ([]fs.DirEntry, error) {
directory, ok := f.File.(fs.ReadDirFile)
if !ok {
return nil, fs.ErrInvalid
}
entries := make([]fs.DirEntry, 0)
for {
count := n
if n > 0 {
count -= len(entries)
}
batch, err := directory.ReadDir(count)
for _, entry := range batch {
if staticComponentAllowed(entry.Name(), f.directory == ".") {
entries = append(entries, entry)
}
}
if err != nil || n <= 0 || len(entries) >= n {
return entries, err
}
if len(batch) == 0 {
// A positive-count ReadDir must either advance or return an
// error. Never spin if an implementation violates that contract.
return entries, io.ErrNoProgress
}
// A batch containing only hidden entries is not the end of the
// directory: continue until the public count or a real error.
}
}
Loading
Loading