webdav

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: BSD-3-Clause Imports: 20 Imported by: 0

README

go-filesystems/webdav

webdav

Go Reference CI

Pure-Go (CGO=0) WebDAV server (RFC 4918) that exports any go-filesystems/interface Filesystem as an ordinary net/http.Handler — so every driver in the family becomes something a browser can read, curl can drive, and the Finder or Explorer can mount, with authentication and TLS.

No kernel extension. No FSKit entitlement. No cgo. No root.

Why WebDAV alongside NFS

go-filesystems/nfs already makes a Filesystem mountable. This module exists because HTTP gives three things NFSv3 structurally cannot, and each of them decided part of the design.

1. Authentication and confidentiality. NFSv3's AUTH_UNIX is a claim, not a proof: the client says "uid 501" and the wire cannot disagree. There is no encryption at all. That is acceptable on 127.0.0.1 and unusable anywhere else. WebDAV is HTTP, so it gets Basic and Bearer authentication here and TLS from the http.Server underneath.

2. A write model that matches the contract. NFS WRITE names an offset, and Filesystem has no positional write, so the NFS server must read the file, splice, and write it back — O(filesize) per request. PUT sends the whole body and is therefore exactly WriteFile. Measured on the same 64 MiB FAT32 image, same machine (16 MiB payload, fresh image per run):

throughput
fat32.WriteFile, no HTTP at all — the driver's own ceiling 6.6 MB/s
PUT over this server 6.6 MB/s
WRITE over go-filesystems/nfs 90 kB/s

Median of five alternating runs, 16 MiB into a freshly formatted 64 MiB image each time, every binary built before the timed region. The two columns are indistinguishable: PUT costs the driver's own write and nothing measurable on top, which is about 70× what the NFS server manages against the same driver. The transport is not the bottleneck; the driver is. That is the whole point — and it is a property of the shape of the operation, not of tuning. On a Linux CI runner the same PUT measures ~30 MB/s, so treat the ratio, not the absolute number, as the result.

3. A client every machine already has. GET on a file returns its bytes, so a browser, curl, wget and every HTTP library are clients without mounting anything.

The cost is equally honest: WebDAV is not a POSIX filesystem. No file descriptors, no byte-range locks, no partial writes, and a mounted WebDAV volume rewrites a whole file on every save.

Install

go get github.com/go-filesystems/webdav

Usage

fsys, err := fat32.Open("disk.img", -1)
if err != nil {
    log.Fatal(err)
}
defer fsys.Close()

h, err := webdav.New(fsys)          // read-only by default
if err != nil {
    log.Fatal(err)
}
log.Fatal(http.ListenAndServe("127.0.0.1:8080", h))

Then, with no mount at all:

curl -s http://127.0.0.1:8080/HELLO.TXT
curl -s -H 'Range: bytes=0-15' http://127.0.0.1:8080/SUB/BIG.BIN
open http://127.0.0.1:8080/                       # a browser reads it
Mounting
# macOS — Finder: Go ▸ Connect to Server (⌘K), then http://127.0.0.1:8080/
# macOS — command line (needs root, which is mount_webdav's requirement, not this server's):
sudo mount_webdav -S -v img http://127.0.0.1:8080/ /Volumes/img

# Linux
sudo mount -t davfs http://127.0.0.1:8080/ /mnt/img

# Windows
net use X: http://127.0.0.1:8080/

Methods

OPTIONS, PROPFIND (Depth 0 and 1, allprop and propname), GET and HEAD with Range, PUT, MKCOL, DELETE, MOVE, COPY, PROPPATCH, LOCK and UNLOCK.

Read-only exports advertise DAV: 1 and an Allow without the write verbs; ReadWrite() exports advertise DAV: 1, 2 — class 2 being the lock support the macOS client insists on before it will write.

Partial writes

A PUT carrying a Content-Range replaces a byte interval in place, through the optional filesystem.WritableFile capability that interface v0.3.0 added and fat32 v0.3.0 implements:

printf 'ZZZZZZ' | curl -X PUT -H 'Content-Range: bytes 4-9/131072' \
    --data-binary @- http://127.0.0.1:8080/SUB/BIG.BIN

Six bytes changed in a 128 KiB file cost one WriteAt. This is precisely the operation that reduces the NFS server to 90 kB/s — without a positional write, patching sixteen bytes of a 4 GiB file means reading, splicing and writing back 8 GiB — so a driver that lacks the capability is answered 501 Not Implemented rather than served the slow way. Quietly falling back would hand a client that asked for the cheap operation the most expensive one the module has, and hide it behind a 204. A partial PUT never extends a resource: a range past the end is 416, like an unsatisfiable GET.

Range is served through the optional filesystem.Opener capability, so a byte range costs a ReadAt and not a whole-file read. A driver that does not implement Opener still works; it just reads the file.

Locking is real, not a stub

The macOS WebDAV client will not write to a server that does not advertise class 2, so locking is not optional in practice. This module implements exclusive write locks with genuine enforcement — a LOCK creates a token, a conflicting request without that token is refused 423 Locked, If: headers are parsed, locks are depth-aware, they expire on a timeout and are swept. A shared-lock request is refused rather than silently granted as exclusive.

It is a server-side lock table, in memory, scoped to the Handler: it coordinates the clients of one export and makes no claim about anything else touching the image. Locks do not survive a restart. That is stated because a lock that quietly means less than the client thinks is worse than no lock.

Isolation

The isolation boundary is the image file and the process. A Handler can reach exactly one Filesystem and nothing else:

  • it never opens a host path and never resolves a name against the host;
  • every request path is percent-decoded first and then normalised, with .. clamped at the root, so no URL — including one spelling the traversal %2e%2e, %2E%2E%2F, or a doubly-encoded %252e%252e — can name anything outside the image;
  • symbolic links are read, not followed: a link's target is reported as a property and is never resolved by this server, so a link pointing at /etc/passwd is a string, not a door.

This is tested as a security property, not as a formality — see isolation_test.go.

Embedding many is cheap by construction: a Handler is an http.Handler over one Filesystem, so one image per tenant and one Handler per image works either on a single mux or in a process each.

Security posture

Exports are read-only unless ReadWrite() is passed. Most of what this is pointed at is a forensic or build artefact, and an accidental write to one is unrecoverable.

Nothing in this package reads a file, an environment variable, or a well-known location. Every credential, every certificate, and the filesystem itself arrive as arguments — there is no configuration surface a deployment could get wrong from a distance.

h, err := webdav.New(fsys,
    webdav.ReadWrite(),
    webdav.WithBasicAuth("img", func(user, pass string) bool {
        // Constant-time: a == comparison leaks the secret one byte at a time.
        return webdav.Verify(user, wantUser) && webdav.Verify(pass, wantPass)
    }),
)

Verify exists so that the obvious verifier is also the correct one. A Handler with credentials configured answers 426 Upgrade Required on a plaintext connection unless the peer is loopback or the caller passed AllowInsecureAuth() — Basic sends the password on every request with nothing but base64 over it.

TLS is deliberately the caller's, because a net/http.Server is already where certificates, ALPN and client-certificate policy live:

srv := &http.Server{Addr: ":8443", Handler: h, TLSConfig: myTLSConfig}
log.Fatal(srv.ListenAndServeTLS("", ""))

Verified against a real client

fat32demo/ serves a genuine FAT32 image and is this repository's end-to-end harness. The proof that matters is a client nobody here wrote reading bytes out of a real on-disk image and getting the digest the driver gives directly:

driver /SUB/BIG.BIN : a2706a20394e48179a86c71e82c360c2960d3652340f9b9fdb355a42e3ac7691
curl   /SUB/BIG.BIN : a2706a20394e48179a86c71e82c360c2960d3652340f9b9fdb355a42e3ac7691

CI runs that on every push against three clients nobody here wrote: curl, cadaver, and a davfs2 kernel mount — plus the isolation attempts below and a Content-Range round trip.

One honest caveat, because it was measured rather than assumed. Under the davfs2 mount, ls returns EINVAL. That is a davfs2 client bug, not a response this server gets wrong: with debug most its log shows FUSE_READDIR failing without issuing any HTTP request, moments after the same process parsed our Depth: 1 PROPFIND (207, read in full) and logged added /SUB/ and added /HELLO.TXT. The listing was sent, accepted and understood; davfs2 1.7.0 cannot hand it to the kernel on this FUSE version. Reads through that same mount return the driver's exact digest, and cadaver — the other neon-based client — lists both collections correctly. CI therefore asserts the mount, the lookup, the read and the digest, and reports the readdir quirk without failing on it.

License

BSD-3-Clause.

Documentation

Overview

Package webdav implements a read/write WebDAV server (RFC 4918) that exports any github.com/go-filesystems/interface.Filesystem as an net/http.Handler.

It turns every go-filesystems driver — ext4, xfs, btrfs, zfs, ntfs, fat32, exfat, hfsplus, apfs, iso9660, squashfs, ufs, ffs, uefi, oci — into something a browser can read, a Finder or Explorer window can mount, and `curl` can drive, from one pure-Go binary with no cgo and no root.

Why WebDAV alongside NFS

github.com/go-filesystems/nfs already makes a Filesystem mountable. This module exists because HTTP gives three things NFSv3 structurally cannot, and each of them decided part of this design:

  • Authentication and confidentiality. NFSv3's AUTH_UNIX is a claim, not a proof: the client says "uid 501" and the wire cannot disagree, and there is no encryption at all. That is acceptable on 127.0.0.1 and unusable anywhere else. WebDAV is HTTP, so it gets Basic and Bearer authentication and TLS from the transport underneath it — see WithBasicAuth, WithBearerAuth and the TLS note below.
  • A write model that matches the contract as it stands today. NFS WRITE names an offset, and github.com/go-filesystems/interface.Filesystem has no positional write, so the NFS server has to read the file, splice and write it back — O(filesize) per request, measured at 90 kB/s over a real mount. WebDAV PUT sends the whole body and is therefore exactly github.com/go-filesystems/interface.Filesystem.WriteFile: one write of one file, at the driver's own speed. And where a client does want a partial write, a PUT carrying a Content-Range is served through github.com/go-filesystems/interface.WritableFile — one WriteAt, not a read-splice-write — or refused 501 if the driver has no positional write, rather than emulated at the cost the client was trying to avoid.
  • A client every machine already has. A GET on a file returns its bytes, so a browser, `curl`, `wget` and every HTTP library are clients without mounting anything.

The cost is equally honest: WebDAV is not a POSIX filesystem. There are no file descriptors, no byte-range locks, no partial writes, and a mounted WebDAV volume rewrites a whole file on every save.

Serving one

The handler is an ordinary net/http.Handler, which is what makes it cheap to embed and cheap to run many of: one image per tenant, one Handler per image, either on one mux or one process each.

fsys, err := fat32.Open("disk.img", -1)
if err != nil {
	return err
}
defer fsys.Close()

h, err := webdav.New(fsys, webdav.ReadWrite())
if err != nil {
	return err
}
return http.ListenAndServe("127.0.0.1:8080", h)

TLS is the caller's, deliberately

This package does not open a listener and has no TLS configuration of its own. A net/http.Handler is served over TLS by the net/http.Server that wraps it, which is where a caller's certificates, key material, ALPN, and client-certificate policy already live:

srv := &http.Server{Addr: ":8443", Handler: h, TLSConfig: myTLSConfig}
return srv.ListenAndServeTLS("", "")

Owning a TLSConfig here would mean either reading key material from a fixed path — which an embedded caller must never have decided for it — or duplicating a configuration surface the standard library already has. Nothing in this package reads a file, an environment variable or a well-known location: every credential, every certificate and the filesystem itself arrive as arguments.

Isolation

The isolation boundary is the image file and the process, not this code. A Handler can reach exactly one Filesystem and nothing else: it never opens a host path, never resolves a name against the host, and every request path is normalised with ".." clamped at the root before it is used, so no URL — including one that spells the traversal "%2e%2e" — can name anything outside the image. Symbolic links are read, not followed: a link's target is reported as a property and is never resolved by this server.

Security posture

Exports are read-only unless ReadWrite is passed; most of what this is pointed at is a forensic or build artefact, and an accidental write to one is unrecoverable.

Authentication is optional and, when configured, refused over cleartext: Basic sends the password in every request with nothing but base64 over it, so a Handler with credentials configured answers 426 Upgrade Required on a plaintext connection unless the peer is loopback or the caller passed AllowInsecureAuth. See WithBasicAuth.

Index

Constants

View Source
const (
	// StatusMulti is 207 Multi-Status (RFC 4918 §11.1): the body carries one
	// status per resource.
	StatusMulti = 207
	// StatusUnprocessable is 422 Unprocessable Entity (RFC 4918 §11.2):
	// well-formed XML the server cannot act on.
	StatusUnprocessable = 422
	// StatusLocked is 423 Locked (RFC 4918 §11.3).
	StatusLocked = 423
	// StatusFailedDependency is 424 Failed Dependency (RFC 4918 §11.4): this
	// resource was not touched because another one in the same request
	// failed.
	StatusFailedDependency = 424
	// StatusInsufficientStorage is 507 Insufficient Storage (RFC 4918 §11.5).
	StatusInsufficientStorage = 507
)

WebDAV extends HTTP's status codes; the ones RFC 4918 adds and this module uses are spelled out because net/http has no constants for them.

Variables

View Source
var (
	// ErrLocked reports a resource locked by a token the request did not
	// submit. It becomes 423 Locked.
	ErrLocked = errors.New("webdav: resource is locked")
	// ErrNoSuchLock reports a token that names no live lock. It becomes 409
	// Conflict for UNLOCK and 412 for a refresh, per RFC 4918.
	ErrNoSuchLock = errors.New("webdav: no such lock")
	// ErrLockTableFull reports the lock table hitting maxLocks.
	ErrLockTableFull = errors.New("webdav: lock table full")
)

Lock errors, mapped to wire statuses by [writeLockError].

View Source
var (
	// ErrNilFilesystem reports New called with no filesystem. It is caught
	// here because the alternative is a nil dereference on the first request
	// — long after the mistake, in a connection goroutine, with no recover.
	ErrNilFilesystem = errors.New("webdav: nil filesystem")
	// ErrPrefix reports a prefix that is not a clean absolute path without a
	// trailing slash, e.g. "/files".
	ErrPrefix = errors.New("webdav: prefix must be a clean absolute path with no trailing slash")
	// ErrNilVerifier reports an authentication option given no verifier. A
	// nil one would accept every credential, which is worse than no
	// authentication at all because it looks like some.
	ErrNilVerifier = errors.New("webdav: nil credential verifier")
)

Errors returned by New.

Functions

func Verify

func Verify(got, want string) bool

Verify reports whether got equals want without leaking, through timing, where the two first differ.

It exists so that the obvious implementation of a WithBasicAuth verifier is also the correct one. A verifier written with == compares byte by byte and stops at the first mismatch, which lets a client recover a secret one character at a time; that is a real attack on a network service and an easy one to write by accident. It does not hide the length, which no comparison can.

Types

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler serves one github.com/go-filesystems/interface.Filesystem over WebDAV. It is an ordinary net/http.Handler: it opens no listener, holds no TLS configuration and reads no file, so a caller composes it with net/http.ServeMux, wraps it in whatever middleware it already has, and terminates TLS in its own net/http.Server.

That is also what makes the intended deployment cheap: one disk image per tenant, one Handler per image, either many on one mux or one per process. A Handler can reach exactly one Filesystem and nothing else.

The zero value is not usable; call New.

func New

func New(fsys filesystem.Filesystem, opts ...Option) (*Handler, error)

New returns a Handler exporting fsys, read-only unless ReadWrite is passed.

It returns an error if an option is malformed, or if the system CSPRNG is unavailable — which would make lock tokens guessable, so the server refuses to start rather than start insecurely (see lock.go).

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements net/http.Handler.

type Option

type Option func(*Handler) error

Option configures a Handler.

func AllowInsecureAuth

func AllowInsecureAuth() Option

AllowInsecureAuth permits credentials over a cleartext, non-loopback connection.

Without it, a Handler that has authentication configured answers 426 Upgrade Required on such a connection instead of reading the Authorization header, because a Basic password crosses that link in the clear on every single request and a server that accepts it teaches its users to send it. Loopback is already exempt without this option — there is no link to intercept — and so is any request that arrived over TLS.

It exists for the one honest case: a Handler already behind a TLS terminator the caller trusts, reached over a private link. Passing it because a certificate was inconvenient is how the password leaks.

func ReadWrite

func ReadWrite() Option

ReadWrite makes the export writable.

Exports are read-only by default, following github.com/go-filesystems/nfs: most of what this module is pointed at is a forensic or build artefact, and an accidental write to one is unrecoverable. A read-only Handler answers every mutating method 403 Forbidden and does not advertise them in OPTIONS, so a client's own check agrees with the server's before it tries.

func WithBasicAuth

func WithBasicAuth(realm string, verify func(user, pass string) bool) Option

WithBasicAuth requires HTTP Basic credentials that verify accepts.

The verifier is a function rather than a user/password pair on purpose: this package must never be the place a credential is written down. Where the caller's credentials come from — a keyring, a database, an environment the caller controls, a token service — is the caller's business, and a callback is the only shape that does not force it into this API. Nothing here is read from a fixed location.

The verifier is called with attacker-controlled strings and must compare in constant time; Verify does that for the common case of a fixed credential the caller holds in memory.

Basic sends the password on every request with nothing but base64 over it, so a Handler with any authentication configured refuses to read credentials from a cleartext, non-loopback connection at all: see AllowInsecureAuth.

Basic and Bearer may both be configured; a request satisfying either is accepted, and the challenge offers both.

func WithBearerAuth

func WithBearerAuth(realm string, verify func(token string) bool) Option

WithBearerAuth requires an RFC 6750 bearer token that verify accepts. The same rules as WithBasicAuth apply: the verifier is supplied by the caller, must compare in constant time, and is not consulted over a cleartext non-loopback connection.

func WithCapacity

func WithCapacity(total, avail uint64) Option

WithCapacity sets the total and available byte counts reported by the RFC 4331 quota properties, which is what a mounted volume shows as its size and free space.

It exists because github.com/go-filesystems/interface.Filesystem has no statfs operation, so this module genuinely cannot know. Rather than invent a plausible number — which would make a volume's free-space display confidently wrong, and would make a client refuse a write it could actually have done — a Handler with no capacity set omits both properties, which clients read as "unknown", and the caller who does know (it opened the image, so it knows its size) can say so.

func WithMaxBody

func WithMaxBody(n int64) Option

WithMaxBody bounds the number of bytes a PUT may carry. See [defaultMaxBody] for why there is a bound at all. A value <= 0 restores the default rather than removing the bound, because "no limit" is not something this server can offer: the body is buffered in full.

func WithPrefix

func WithPrefix(prefix string) Option

WithPrefix mounts the export under a URL prefix, e.g. "/files". The prefix must be a clean absolute path with no trailing slash.

It exists rather than deferring to net/http.StripPrefix because a multistatus body quotes absolute hrefs back to the client: a stripped prefix would be missing from every href in it, and the client would follow them to the wrong place. The Handler therefore has to know its own mount point rather than have it hidden from it.

type TimeStat

type TimeStat interface {
	ModTime() int64 // seconds since the Unix epoch
}

TimeStat is the optional capability a driver's github.com/go-filesystems/interface.Stat may implement to report a real modification time.

No driver in the fleet does today, so every resource this server exports currently reports the Handler's start time as its getlastmodified. The probe exists so that the day a driver starts reporting mtime, clients get real times without a change here — and so that the gap is visible in the API instead of buried in a comment. It is spelled exactly as github.com/go-filesystems/nfs.TimeStat so a driver satisfies both servers with one method.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL