Compare commits

..
17 Commits
Author SHA1 Message Date
Henrique Dias 04c863aefc test: compare flipCase results with OS-native separators
The expectations were written with forward slashes, but flipCase builds a
path to stat and so separates it the way the running system does.
2026-09-04 14:36:46 +02:00
Henrique Dias 3ded167a52 fix: authorize every path an operation touches
Recursive COPY, MOVE, DELETE and PROPFIND were checked only against the
requested path, reaching descendants their rules deny. A broad rule shadowed
a narrower one naming a collection. LOCK was allowed on any permission, so a
read-only user could create files and block writers. Rules compared case
where the backing file system does not.
2026-09-04 14:32:32 +02:00
Henrique Dias 081d20405f docs: add CLAUDE.md 2026-09-04 12:53:44 +02:00
Mao Mao 4733aa03c4 feat: support CORS Private Network Access (#350) 2026-08-28 15:27:04 +02:00
renovate[bot] 6dc4d8de20 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to afebf4c (#351) 2026-08-28 15:24:17 +02:00
renovate[bot] d0ad4623a9 chore(deps): update all non-major dependencies (#355) 2026-08-28 15:24:08 +02:00
renovate[bot] 203d2f72bf chore(deps): update all non-major dependencies (#352) 2026-08-19 09:23:05 +02:00
Henrique Dias f869dd6276 Merge commit from fork
* fix: resolve dot segments before checking path rules (GHSA-chxv-mvjv-f92j)

* fix: match trailing-slash path rules against the bare collection

* fix: match destination rules against the URL path when no prefix is set

* fix: restrict collections named by a rule without granting access to them

* docs: cleanup
2026-08-05 10:18:13 +02:00
Henrique Dias c04649bf40 docs: add security policy 2026-08-02 07:28:17 +02:00
snowy_smile 390fe21ed9 fix: use slash-separated lock paths on Windows (#344) 2026-07-25 07:15:27 +02:00
renovate[bot] 44e5e02dd3 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to d701c51 (#349) 2026-07-25 07:14:36 +02:00
renovate[bot] d59dd02f96 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to ff03daf (#347) 2026-07-19 09:19:16 +02:00
renovate[bot] 10183d09bc chore(deps): update actions/setup-go action to v7 (#348) 2026-07-19 09:16:47 +02:00
Henrique Dias 1dceeb296a docs: remove deprecated go report card 2026-07-12 09:52:30 +02:00
renovate[bot] 2bf7130f56 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 243e02a (#345) 2026-07-12 09:51:24 +02:00
Higanoneko 7ea4cec229 feat: add multi directories support (#335) 2026-07-12 09:51:10 +02:00
renovate[bot] de2ac9d327 chore(deps): update all non-major dependencies (#346) 2026-07-11 09:37:17 +02:00
23 changed files with 2516 additions and 179 deletions
+95
View File
@@ -0,0 +1,95 @@
# CLAUDE.md
Guidance for Claude when working in this repository (`hacdias/webdav`).
## Handling security advisories
Advisory state lives on GitHub and is driven with the `gh` CLI: `triage → draft → published`, plus `closed`. Reports are routed as per [SECURITY.md](../SECURITY.md); only `5.x` is supported.
### 1. Fetch
```bash
# List by state (also: published, draft, closed)
gh api '/repos/hacdias/webdav/security-advisories?state=triage&per_page=100' \
--jq '.[] | {ghsa_id, severity, summary, state}'
# Full report for one advisory
gh api /repos/hacdias/webdav/security-advisories/GHSA-xxxx-xxxx-xxxx \
--jq '.summary, "---", .description'
```
Always pull the published and remaining triage sets too, to dedup against.
### 2. Verify — do NOT trust the report text
Read the source at HEAD and reproduce the claim; a failing `makeTestServer` case is better evidence than reading the matcher. Reach one verdict per advisory:
- **CONFIRMED** — defect exists at HEAD. Quote the exact `file:line`.
- **FIXED** — already patched; find the fix commit and the release carrying it.
- **FALSE / NOT APPLICABLE** — claim is wrong, or targets a different project.
- **NOT EXPLOITABLE** — pattern exists but no code path reaches the precondition.
- **DUPLICATE** — of a published advisory, or of another triage advisory.
Common traps:
- **"Incomplete fix of a prior advisory."** Read the original fix commit and confirm the specific sibling path is still unguarded. `GHSA-chxv-mvjv-f92j` already cleans paths in `newRequest` and matches trailing-slash rules against the bare collection.
- **Wrong project.** Confirm the cited files, symbols, and options exist here — reports sometimes describe a fork or another WebDAV server.
- **Containment vs authorization.** `golang.org/x/net/webdav` applies `slashClean` and keeps requests inside the served root, so "traversal out of `directory`" is usually not the defect. The real class is the authorization layer disagreeing with the filesystem layer about which file a request names.
- **No `users:` means no authentication, by design** — it warns at startup. Not a vulnerability, but it changes the privilege precondition.
- **Overlapping reports.** Several triage advisories may share one root cause: consolidate into one, close the rest as duplicates.
Record per advisory: verdict, `file:line` evidence, preconditions (default config? needs `directories:`? platform-specific? auth required?), disposition.
### 3. Severity
Set a CVSS v3.1 vector — GitHub derives the score and severity from it, overriding the plain `severity` field. Encode the real preconditions so the band is defensible: a required configuration or platform (case-insensitive filesystem, `directories:`) is **AC:H**; needing an account is **PR:L**. Rate the base case as a config with a `users:` block, and note the unauthenticated vector in the body when it is materially worse. Don't let an incomplete-fix follow-up outrank its parent.
```bash
gh api -X PATCH /repos/hacdias/webdav/security-advisories/GHSA-xxxx-xxxx-xxxx \
-f cvss_vector_string='CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N' \
--jq '{ghsa_id, severity, score: .cvss.score, vector: .cvss.vector_string}'
```
### 4. Rewrite the title and body
The title is `summary`: concise and sentence-case, stating the vulnerability class then the mechanism, e.g. `Authorization bypass: path rules can be evaded with dot segments or a bare collection name`.
Rewrite `description` into the sections below, reusing the reporter's own wording where it is accurate. Drop the greeting and anything step 2 disproved. Use `###` headings, keep this order, omit what doesn't apply. Keep the maintainer's voice — first person belongs only in quoted PoC steps.
| Section | Contents |
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
| `Summary` | The defect and its root cause, naming the file and function, quoting the pre-fix code. |
| `Impact` | Who can exploit it and what they get. State what is *not* affected. |
| `Proof of concept` | Config and steps trimmed to the essentials, with observed results. |
| `Patches` | `Fixed in **vX.Y.Z**. Upgrade to that version or later.` plus what the fix does and why it sits where it does. |
| `Workarounds` | What the operator can do themselves. `None.` if nothing helped, saying why. |
| `Out of scope` | What the report claimed that is deliberately not treated as a vulnerability, and why. |
| `References` | Related issues, commits, published advisories. |
Send it as a file so the Markdown survives shell quoting:
```bash
jq -Rs '{description: .}' desc.md \
| gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx --input -
```
### 5. Affected versions
The package is always `{ecosystem: "go", name: "github.com/hacdias/webdav/v5"}`. `vulnerable_version_range` ends at the last release before the fix; add a lower bound when the defect was introduced in a known version, confirming with `git log -S` and `git tag --contains`. `patched_versions` is the release carrying the fix.
```bash
printf '%s' '{"vulnerabilities":[{"package":{"ecosystem":"go","name":"github.com/hacdias/webdav/v5"},"vulnerable_version_range":">= 5.10.0, <= 5.14.1","patched_versions":"5.14.2","vulnerable_functions":[]}]}' \
| gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx --input -
```
### 6. Move state
```bash
gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx -f state=draft # ready to publish
gh api -X PATCH .../security-advisories/GHSA-xxxx-xxxx-xxxx -f state=closed # duplicate / N-A / not-exploitable
```
- **CONFIRMED** → fix, release, set `patched_versions` → draft, then publish once the release is out.
- **DUPLICATE / NOT APPLICABLE / NOT EXPLOITABLE** → closed.
The REST API cannot post advisory comments. Replies to reporters must be posted manually in the UI — draft the text for the maintainer.
+2 -2
View File
@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.27.x"
- run: go build .
env:
CGO_ENABLED: '0'
+2 -2
View File
@@ -13,9 +13,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.27.x"
- uses: golangci/golangci-lint-action@v9
with:
version: "latest"
+2 -2
View File
@@ -15,9 +15,9 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.27.x"
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
+14 -5
View File
@@ -10,12 +10,21 @@ on:
jobs:
test:
name: test
runs-on: ubuntu-latest
name: test (${{ matrix.os }})
strategy:
matrix:
os:
- ubuntu-latest
- windows-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
- name: Run test with coverage
go-version: "1.27.x"
- name: Run test with race detector and coverage
if: runner.os != 'Windows'
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Run test with coverage
if: runner.os == 'Windows'
run: go test "-coverprofile=coverage.txt" -covermode=atomic ./...
+27 -2
View File
@@ -1,6 +1,5 @@
# webdav
[![Go Report Card](https://goreportcard.com/badge/github.com/hacdias/webdav?style=flat-square)](https://goreportcard.com/report/hacdias/webdav)
[![Version](https://img.shields.io/github/release/hacdias/webdav.svg?style=flat-square)](https://github.com/hacdias/webdav/releases/latest)
[![Docker Pulls](https://img.shields.io/docker/pulls/hacdias/webdav?style=flat-square)](https://hub.docker.com/r/hacdias/webdav)
@@ -107,9 +106,21 @@ behindProxy: false
# that is /data.
directory: /data
# Alternatively, replace 'directory' with 'directories' to expose multiple
# directories as virtual root entries. This option is mutually exclusive with
# 'directory' in the same scope. Rules should include the virtual mount name,
# such as /media/public/access/.
# directories:
# - media: /data/media
# - /data/archive
# - name: backups
# path: /data/backups
# The default permissions for users. This is a case insensitive option. Possible
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
# permissions. For example, to allow to read and create, set "RC". Default is "R".
# LOCK counts as a write: it needs U on a path that exists and C on one that does
# not, since locking a path that does not exist creates it.
permissions: R
# The default permissions rules for users. Default is none. Rules are applied
@@ -143,6 +154,8 @@ cors:
# Whether or not CORS configuration should be applied. Default is 'false'.
enabled: true
credentials: true
# Allow Private Network Access preflight requests. Default is 'false'.
allow_private_network: false
# The following are the default CORS settings when it is enabled.
allowed_hosts:
- '*'
@@ -221,9 +234,21 @@ users:
# noPassword: true
```
### Rules
Rules are matched against the request path after dot segments have been resolved, so `/public/../secret/file` is matched as `/secret/file`. The last rule that matches wins.
A `path` rule is a prefix match. A rule written with a trailing slash also covers the collection it names, so `path: /secret/` applies to a request for `/secret` as well. Such a rule can only restrict that collection: acting on the collection itself also requires the permissions that apply outside the rule, since the operation takes place in the parent collection.
A `regex` rule is matched literally against the path, and gets none of the above handling. In particular `regex: "^/secret/"` does **not** match a request for `/secret` itself. Write `regex: "^/secret(/|$)"` if you want to cover the collection too.
Rules apply to every path an operation touches, not only the one it names. Collection listings leave out entries the rules deny, copying a collection leaves those entries behind, and a `MOVE` or `DELETE` that would act on a denied descendant is refused outright.
Rules follow the case sensitivity of the file system, which each served directory is probed for at startup. Where names are case-insensitive, as on APFS and NTFS, `path: /secret/` also covers `/SECRET/`, a `regex` is matched against the folded path as well as the path as written, and Unicode normal forms count as one name. Elsewhere rules are matched exactly, since `/secret` and `/SECRET` are then different directories.
### CORS
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `credentials` to `true` will allow you to:
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `allow_private_network` to `true` to allow Private-Network-Access preflight requests. Setting `credentials` to `true` will allow you to:
1. Use `withCredentials = true` in javascript.
2. Use the `username:password@host` syntax.
+20
View File
@@ -0,0 +1,20 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 5.x | :white_check_mark: |
| < 5.x | :x: |
## Reporting a Vulnerability
- **Critical:** report privately via the [Security](https://github.com/hacdias/webdav/security) page.
- **Non-critical:** open a public issue so the community can help; it'll be labeled it as a security issue.
Please include, where possible:
- The commit the issue was found at
- A plaintext proof of concept (no binaries)
- Steps to reproduce
- Recommended remediation, if any
+9 -12
View File
@@ -1,6 +1,6 @@
module github.com/hacdias/webdav/v5
go 1.25.0
go 1.26.0
require (
github.com/coreos/go-systemd/v22 v22.7.0
@@ -9,28 +9,25 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/studio-b12/gowebdav v0.12.0
github.com/stretchr/testify v1.12.1
github.com/studio-b12/gowebdav v0.13.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.53.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260708182226-cdce021fa6c7
golang.org/x/net v0.56.0
golang.org/x/crypto v0.55.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb
golang.org/x/net v0.58.0
golang.org/x/text v0.41.0
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.47.0 // indirect
)
+16 -23
View File
@@ -1,8 +1,6 @@
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -19,8 +17,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
@@ -41,10 +37,10 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -53,20 +49,17 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260708182226-cdce021fa6c7 h1:XWbcG8uLNW2EmI8DBzFXp7XwIxmrYK6s7ME2iyyq7Cg=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260708182226-cdce021fa6c7/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb h1:3OQBwC/IAO9+1/yWsvWkE1Lw5InpHGlZxXZHUcWBouA=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+76
View File
@@ -0,0 +1,76 @@
package lib
import (
"os"
"path/filepath"
"runtime"
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// defaultCaseInsensitiveFS is true on platforms that are case-insensitive by
// default, and false on platforms that are case-sensitive by default. It is used
// as a fallback when probing the file system fails.
const defaultCaseInsensitiveFS = runtime.GOOS == "darwin" || runtime.GOOS == "windows"
// foldPath is used to compare paths in a case-insensitive manner, normalizing
// them to NFC and converting to lower case.
func foldPath(p string) string {
return norm.NFC.String(strings.ToLower(p))
}
// caseInsensitiveFS probes the file system backing dir to see if it is case
// insensitive. It falls back to the platform default when the probe fails.
func caseInsensitiveFS(dir string) bool {
info, err := os.Stat(dir)
if err != nil {
return defaultCaseInsensitiveFS
}
flipped, ok := flipCase(dir)
if !ok {
return defaultCaseInsensitiveFS
}
other, err := os.Stat(flipped)
if err != nil {
return false
}
return os.SameFile(info, other)
}
// flipCase swaps the case of the first letter in the last element of dir whose
// case mapping round-trips, reporting false when it holds none.
func flipCase(dir string) (string, bool) {
flipped := false
base := strings.Map(func(r rune) rune {
if flipped {
return r
}
switch {
case unicode.IsLower(r):
if u := unicode.ToUpper(r); unicode.ToLower(u) == r {
flipped = true
return u
}
case unicode.IsUpper(r):
if l := unicode.ToLower(r); unicode.ToUpper(l) == r {
flipped = true
return l
}
}
return r
}, filepath.Base(dir))
if !flipped {
return "", false
}
return filepath.Join(filepath.Dir(dir), base), true
}
+181 -7
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"github.com/go-viper/mapstructure/v2"
@@ -23,6 +24,8 @@ const (
DefaultPrefix = "/"
)
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
type Config struct {
UserPermissions `mapstructure:",squash"`
Debug bool
@@ -100,6 +103,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
cfg := &Config{}
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
directoryMountsDecodeHook(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
mapstructure.TextUnmarshallerHookFunc(),
@@ -108,12 +112,28 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
return nil, err
}
err = applyDirectoryConfig(v, flags, &cfg.UserPermissions, "directory", "directories", nil)
if err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
// Cascade user settings
for i := range cfg.Users {
if !v.IsSet(fmt.Sprintf("Users.%d.Directory", i)) {
userDirectoryKey := fmt.Sprintf("Users.%d.Directory", i)
userDirectoriesKey := fmt.Sprintf("Users.%d.Directories", i)
if !v.IsSet(userDirectoryKey) {
cfg.Users[i].Directory = cfg.Directory
}
err := applyDirectoryConfig(v, flags, &cfg.Users[i].UserPermissions, userDirectoryKey, userDirectoriesKey, &cfg.UserPermissions)
if err != nil {
if errors.Is(err, errDirectoryConflict) {
return nil, fmt.Errorf("invalid config: user %q cannot define both directory and directories", cfg.Users[i].Username)
}
return nil, fmt.Errorf("invalid config: user %q: %w", cfg.Users[i].Username, err)
}
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
cfg.Users[i].Permissions = cfg.Permissions
}
@@ -145,6 +165,46 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
return cfg, nil
}
func applyDirectoryConfig(v *viper.Viper, flags *pflag.FlagSet, permissions *UserPermissions, directoryKey, directoriesKey string, inherited *UserPermissions) error {
permissions.directoryExplicit = isExplicitlySet(v, flags, directoryKey)
permissions.directoriesExplicit = isExplicitlySet(v, flags, directoriesKey)
if permissions.directoryExplicit && permissions.directoriesExplicit {
return errDirectoryConflict
}
switch {
case permissions.directoryExplicit:
permissions.Directory = v.GetString(directoryKey)
permissions.useDirectories = false
case permissions.directoriesExplicit:
directories, err := getDirectoryMounts(v, directoriesKey, permissions.Directories)
if err != nil {
return err
}
permissions.Directories = directories
permissions.useDirectories = true
case inherited != nil:
permissions.Directories = append(DirectoryMounts{}, inherited.Directories...)
permissions.useDirectories = inherited.useDirectories
}
return nil
}
func isExplicitlySet(v *viper.Viper, flags *pflag.FlagSet, key string) bool {
if flags != nil && flags.Changed(key) {
return true
}
if v.InConfig(key) {
return true
}
envKey := "WD_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
value, ok := os.LookupEnv(envKey)
return ok && value != ""
}
func (c *Config) Validate() error {
var err error
@@ -188,6 +248,119 @@ func (c *Config) Validate() error {
return nil
}
func directoryMountsDecodeHook() mapstructure.DecodeHookFunc {
mountsType := reflect.TypeOf(DirectoryMounts{})
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if to != mountsType {
return data, nil
}
return decodeDirectoryMounts(data)
}
}
func getDirectoryMounts(v *viper.Viper, key string, fallback DirectoryMounts) (DirectoryMounts, error) {
value := v.Get(key)
if value == nil {
return fallback, nil
}
return decodeDirectoryMounts(value)
}
func decodeDirectoryMounts(data any) (DirectoryMounts, error) {
switch value := data.(type) {
case nil:
return DirectoryMounts{}, nil
case DirectoryMounts:
return value, nil
case []DirectoryMount:
return DirectoryMounts(value), nil
case string:
if value == "" {
return DirectoryMounts{}, nil
}
parts := strings.Split(value, ",")
mounts := make(DirectoryMounts, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
mounts = append(mounts, DirectoryMount{Path: part})
}
return mounts, nil
case []any:
mounts := make(DirectoryMounts, 0, len(value))
for _, item := range value {
mount, err := decodeDirectoryMount(item)
if err != nil {
return nil, err
}
mounts = append(mounts, mount)
}
return mounts, nil
case []string:
mounts := make(DirectoryMounts, 0, len(value))
for _, item := range value {
mounts = append(mounts, DirectoryMount{Path: item})
}
return mounts, nil
default:
return nil, fmt.Errorf("invalid directories: unsupported value %T", data)
}
}
func decodeDirectoryMount(data any) (DirectoryMount, error) {
switch value := data.(type) {
case string:
return DirectoryMount{Path: value}, nil
case map[string]any:
return decodeDirectoryMountMap(value)
case map[any]any:
m := map[string]any{}
for key, value := range value {
keyString, ok := key.(string)
if !ok {
return DirectoryMount{}, errors.New("invalid directories: mount keys must be strings")
}
m[keyString] = value
}
return decodeDirectoryMountMap(m)
default:
return DirectoryMount{}, fmt.Errorf("invalid directories: unsupported mount entry %T", data)
}
}
func decodeDirectoryMountMap(data map[string]any) (DirectoryMount, error) {
_, hasName := data["name"]
_, hasPath := data["path"]
if hasName || hasPath {
name, nameOK := data["name"].(string)
path, pathOK := data["path"].(string)
if !nameOK || !pathOK || len(data) != 2 {
return DirectoryMount{}, errors.New("invalid directories: explicit mount objects must define name and path")
}
return DirectoryMount{Name: name, Path: path}, nil
}
if len(data) != 1 {
return DirectoryMount{}, errors.New("invalid directories: mapped mount entries must have exactly one key")
}
for name, path := range data {
pathString, ok := path.(string)
if !ok {
return DirectoryMount{}, errors.New("invalid directories: mapped mount paths must be strings")
}
return DirectoryMount{Name: name, Path: pathString}, nil
}
return DirectoryMount{}, errors.New("invalid directories: empty mount entry")
}
func (cfg *Config) GetLogger() (*zap.Logger, error) {
loggerConfig := zap.NewProductionConfig()
loggerConfig.DisableCaller = true
@@ -210,10 +383,11 @@ type Log struct {
}
type CORS struct {
Enabled bool
Credentials bool
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
Enabled bool
Credentials bool
AllowPrivateNetwork bool `mapstructure:"allow_private_network"`
AllowedHeaders []string `mapstructure:"allowed_headers"`
AllowedHosts []string `mapstructure:"allowed_hosts"`
AllowedMethods []string `mapstructure:"allowed_methods"`
ExposedHeaders []string `mapstructure:"exposed_headers"`
}
+194 -4
View File
@@ -3,6 +3,7 @@ package lib
import (
"os"
"path/filepath"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
@@ -54,17 +55,25 @@ func TestConfigDefaults(t *testing.T) {
require.EqualValues(t, []string{"*"}, cfg.CORS.AllowedHosts)
require.EqualValues(t, []string{"Authorization", "Content-Type", "Content-Range", "Depth", "Destination", "If", "Lock-Token", "Overwrite", "X-Update-Range"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"COPY", "DELETE", "GET", "HEAD", "LOCK", "MKCOL", "MOVE", "OPTIONS", "PATCH", "POST", "PROPFIND", "PROPPATCH", "PUT", "UNLOCK"}, cfg.CORS.AllowedMethods)
require.False(t, cfg.CORS.AllowPrivateNetwork)
}
func TestConfigCascade(t *testing.T) {
t.Parallel()
// Directories are resolved to absolute paths, which differ by platform
// (for example "/" becomes the current drive root on Windows).
rootDirectory, err := filepath.Abs("/")
require.NoError(t, err)
basicDirectory, err := filepath.Abs("/basic")
require.NoError(t, err)
check := func(t *testing.T, cfg *Config) {
require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
require.False(t, cfg.Permissions.Delete)
require.False(t, cfg.Permissions.Update)
require.Equal(t, "/", cfg.Directory)
require.Equal(t, rootDirectory, cfg.Directory)
require.Len(t, cfg.Rules, 1)
require.Len(t, cfg.Users, 2)
@@ -72,14 +81,14 @@ func TestConfigCascade(t *testing.T) {
require.True(t, cfg.Users[0].Permissions.Create)
require.False(t, cfg.Users[0].Permissions.Delete)
require.False(t, cfg.Users[0].Permissions.Update)
require.Equal(t, "/", cfg.Users[0].Directory)
require.Equal(t, rootDirectory, cfg.Users[0].Directory)
require.Len(t, cfg.Users[0].Rules, 1)
require.True(t, cfg.Users[1].Permissions.Read)
require.False(t, cfg.Users[1].Permissions.Create)
require.False(t, cfg.Users[1].Permissions.Delete)
require.False(t, cfg.Users[1].Permissions.Update)
require.Equal(t, "/basic", cfg.Users[1].Directory)
require.Equal(t, basicDirectory, cfg.Users[1].Directory)
require.Len(t, cfg.Users[1].Rules, 0)
}
@@ -165,6 +174,182 @@ rules = []
})
}
func TestConfigDirectories(t *testing.T) {
t.Parallel()
t.Run("Mixed Entries", func(t *testing.T) {
t.Parallel()
dirC := t.TempDir()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `
directories:
- `+dirC+`
- d2: `+dirD+`
- name: archive
path: `+dirE+`
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
require.Equal(t, dirC, cfg.Directories[0].Path)
require.Equal(t, "d2", cfg.Directories[1].Name)
require.Equal(t, dirD, cfg.Directories[1].Path)
require.Equal(t, "archive", cfg.Directories[2].Name)
require.Equal(t, dirE, cfg.Directories[2].Path)
})
t.Run("JSON", func(t *testing.T) {
t.Parallel()
dirC := t.TempDir()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `{
"directories": [
`+strconv.Quote(dirC)+`,
{ "d2": `+strconv.Quote(dirD)+` },
{ "name": "archive", "path": `+strconv.Quote(dirE)+` }
]
}`, ".json")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, filepath.Base(dirC), cfg.Directories[0].Name)
require.Equal(t, dirC, cfg.Directories[0].Path)
require.Equal(t, "d2", cfg.Directories[1].Name)
require.Equal(t, dirD, cfg.Directories[1].Path)
require.Equal(t, "archive", cfg.Directories[2].Name)
require.Equal(t, dirE, cfg.Directories[2].Path)
})
t.Run("TOML", func(t *testing.T) {
t.Parallel()
dirD := t.TempDir()
dirE := t.TempDir()
cfg := writeAndParseConfig(t, `
[[directories]]
d2 = `+strconv.Quote(dirD)+`
[[directories]]
name = "archive"
path = `+strconv.Quote(dirE)+`
`, ".toml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, "d2", cfg.Directories[0].Name)
require.Equal(t, dirD, cfg.Directories[0].Path)
require.Equal(t, "archive", cfg.Directories[1].Name)
require.Equal(t, dirE, cfg.Directories[1].Path)
})
t.Run("Mutually Exclusive Global Directory Fields", func(t *testing.T) {
t.Parallel()
writeAndParseConfigWithError(t, `
directory: /tmp
directories:
- /tmp
`, ".yml", "directory and directories cannot both be defined")
})
t.Run("Mutually Exclusive User Directory Fields", func(t *testing.T) {
t.Parallel()
writeAndParseConfigWithError(t, `
users:
- username: basic
password: basic
directory: /tmp
directories:
- /tmp
`, ".yml", "cannot define both directory and directories")
})
t.Run("Duplicate Mount Names", func(t *testing.T) {
t.Parallel()
parent := t.TempDir()
dir := filepath.Join(parent, "dup")
require.NoError(t, os.Mkdir(dir, 0775))
writeAndParseConfigWithError(t, `
directories:
- `+dir+`
- dup: /tmp
`, ".yml", "duplicate mount name")
})
t.Run("Cascade Mode", func(t *testing.T) {
t.Parallel()
global := t.TempDir()
single := t.TempDir()
userMulti := t.TempDir()
cfg := writeAndParseConfig(t, `
directories:
- global: `+global+`
users:
- username: inherited
password: inherited
- username: single
password: single
directory: `+single+`
- username: multi
password: multi
directories:
- owned: `+userMulti+`
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.True(t, cfg.Users[0].useDirectories)
require.Equal(t, DirectoryMounts{{Name: "global", Path: global}}, cfg.Users[0].Directories)
require.False(t, cfg.Users[1].useDirectories)
require.Equal(t, single, cfg.Users[1].Directory)
require.True(t, cfg.Users[2].useDirectories)
require.Equal(t, DirectoryMounts{{Name: "owned", Path: userMulti}}, cfg.Users[2].Directories)
})
}
func TestConfigDirectoriesEnvOverrides(t *testing.T) {
global := t.TempDir()
single := t.TempDir()
userMulti := t.TempDir()
t.Setenv("WD_DIRECTORIES", global)
t.Setenv("WD_USERS_1_DIRECTORY", single)
t.Setenv("WD_USERS_2_DIRECTORIES", userMulti)
cfg := writeAndParseConfig(t, `
users:
- username: inherited
password: inherited
- username: single
password: single
- username: multi
password: multi
`, ".yml")
require.NoError(t, cfg.Validate())
require.True(t, cfg.useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Directories)
require.True(t, cfg.Users[0].useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(global), Path: global}}, cfg.Users[0].Directories)
require.False(t, cfg.Users[1].useDirectories)
require.Equal(t, single, cfg.Users[1].Directory)
require.True(t, cfg.Users[2].useDirectories)
require.Equal(t, DirectoryMounts{{Name: filepath.Base(userMulti), Path: userMulti}}, cfg.Users[2].Directories)
}
func TestConfigKeys(t *testing.T) {
t.Parallel()
@@ -172,6 +357,7 @@ func TestConfigKeys(t *testing.T) {
cors:
enabled: true
credentials: true
allow_private_network: true
allowed_headers:
- Depth
allowed_hosts:
@@ -185,6 +371,7 @@ cors:
require.True(t, cfg.CORS.Enabled)
require.True(t, cfg.CORS.Credentials)
require.True(t, cfg.CORS.AllowPrivateNetwork)
require.EqualValues(t, []string{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
@@ -308,8 +495,11 @@ func TestConfigEnv(t *testing.T) {
cfg, err := ParseConfig("", nil)
require.NoError(t, err)
expectedDirectory, err := filepath.Abs("/test")
require.NoError(t, err)
assert.Equal(t, 1234, cfg.Port)
assert.Equal(t, "/test", cfg.Directory)
assert.Equal(t, expectedDirectory, cfg.Directory)
assert.Equal(t, true, cfg.Debug)
require.True(t, cfg.Permissions.Read)
require.True(t, cfg.Permissions.Create)
+87 -49
View File
@@ -3,7 +3,6 @@ package lib
import (
"net/http"
"os"
"strings"
"github.com/rs/cors"
"go.uber.org/zap"
@@ -12,7 +11,8 @@ import (
type handlerUser struct {
User
webdav.Handler
handler webdav.Handler
fs permissionsFS
}
type Handler struct {
@@ -33,52 +33,23 @@ func NewHandler(c *Config) (http.Handler, error) {
h := &Handler{
noPassword: c.NoPassword,
behindProxy: c.BehindProxy,
user: &handlerUser{
User: User{
UserPermissions: c.UserPermissions,
},
Handler: webdav.Handler{
Prefix: c.Prefix,
FileSystem: Dir{
Dir: webdav.Dir(c.Directory),
noSniff: c.NoSniff,
},
LockSystem: &lockSystem{
LockSystem: ls,
directory: c.Directory,
},
Logger: logFunc,
},
},
users: map[string]*handlerUser{},
user: newHandlerUser(User{UserPermissions: c.UserPermissions}, c, ls, logFunc),
users: map[string]*handlerUser{},
}
for _, u := range c.Users {
h.users[u.Username] = &handlerUser{
User: u,
Handler: webdav.Handler{
Prefix: c.Prefix,
FileSystem: Dir{
Dir: webdav.Dir(u.Directory),
noSniff: c.NoSniff,
},
LockSystem: &lockSystem{
LockSystem: ls,
directory: u.Directory,
},
Logger: logFunc,
},
}
h.users[u.Username] = newHandlerUser(u, c, ls, logFunc)
}
if c.CORS.Enabled {
return cors.New(cors.Options{
AllowCredentials: c.CORS.Credentials,
AllowedOrigins: c.CORS.AllowedHosts,
AllowedMethods: c.CORS.AllowedMethods,
AllowedHeaders: c.CORS.AllowedHeaders,
ExposedHeaders: c.CORS.ExposedHeaders,
OptionsPassthrough: false,
AllowCredentials: c.CORS.Credentials,
AllowPrivateNetwork: c.CORS.AllowPrivateNetwork,
AllowedOrigins: c.CORS.AllowedHosts,
AllowedMethods: c.CORS.AllowedMethods,
AllowedHeaders: c.CORS.AllowedHeaders,
ExposedHeaders: c.CORS.ExposedHeaders,
OptionsPassthrough: false,
}).Handler(h), nil
}
@@ -93,6 +64,52 @@ func NewHandler(c *Config) (http.Handler, error) {
return h, nil
}
// newHandlerUser prepares a user for serving, keeping the unwrapped file system
// alongside the handler.
func newHandlerUser(u User, c *Config, ls webdav.LockSystem, logFunc func(*http.Request, error)) *handlerUser {
fs := permissionsFS{fs: buildFileSystem(u.UserPermissions, c.NoSniff), perms: u.UserPermissions}
return &handlerUser{
User: u,
handler: buildWebdavHandler(u.UserPermissions, fs, c.Prefix, ls, logFunc),
fs: fs,
}
}
// buildFileSystem creates the unfiltered [webdav.FileSystem] for a set of user
// permissions, selecting between single-directory and multi-directory backing
// depending on whether directories are configured.
func buildFileSystem(p UserPermissions, noSniff bool) webdav.FileSystem {
if p.useDirectories {
return multiDir{
mounts: p.Directories,
noSniff: noSniff,
}
}
return Dir{
Dir: webdav.Dir(p.Directory),
noSniff: noSniff,
}
}
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions.
func buildWebdavHandler(p UserPermissions, fs permissionsFS, prefix string, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
h := webdav.Handler{
Prefix: prefix,
Logger: logFunc,
FileSystem: fs,
}
if p.useDirectories {
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
} else {
h.LockSystem = newLockSystem(ls, p.Directory)
}
return h
}
// ServeHTTP determines if the request is for this plugin, and if all prerequisites are met.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := h.user
@@ -130,18 +147,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Convert the HTTP request into an internal request type
req, err := newRequest(r, h.user.Prefix)
req, err := newRequest(r, h.user.handler.Prefix)
if err != nil {
lZap.Info("invalid request path or destination", zap.Error(err))
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
return
}
// Checks for user permissions relatively to this PATH.
allowed := user.Allowed(req, func(filename string) bool {
_, err := user.FileSystem.Stat(r.Context(), filename)
fileExists := func(filename string) bool {
_, err := user.fs.Stat(r.Context(), filename)
return !os.IsNotExist(err)
})
}
// Checks for user permissions relatively to this PATH.
allowed := user.Allowed(req, fileExists)
lZap.Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
@@ -150,6 +169,25 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// MOVE and DELETE act on a whole subtree in one call, so every descendant
// needs authorizing here. COPY and PROPFIND go through permFS instead.
if r.Method == "MOVE" || r.Method == "DELETE" {
ok, err := user.fs.allowedThroughout(r.Context(), req.path, func(p Permissions) bool {
return p.Allowed(req, fileExists)
})
if err != nil {
lZap.Error("could not authorize subtree", zap.String("path", req.path), zap.Error(err))
w.WriteHeader(http.StatusForbidden)
return
}
if !ok {
lZap.Info("denied by a rule on a descendant", zap.String("method", r.Method), zap.String("path", req.path))
w.WriteHeader(http.StatusForbidden)
return
}
}
if r.Method == "HEAD" {
w = responseWriterNoBody{w}
}
@@ -165,8 +203,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// collection resources.
//
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
if (r.Method == "GET" || r.Method == "HEAD") && strings.HasPrefix(r.URL.Path, user.Prefix) {
info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix))
if r.Method == "GET" || r.Method == "HEAD" {
info, err := user.fs.Stat(r.Context(), req.path)
if err == nil && info.IsDir() {
r.Method = "PROPFIND"
@@ -187,7 +225,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
// Runs the WebDAV.
user.ServeHTTP(w, r)
user.handler.ServeHTTP(w, r)
}
// getRequestLogger creates a zap.Logger using the request remote ip.
+723 -22
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
@@ -85,6 +86,29 @@ func TestServerDefaults(t *testing.T) {
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
}
func TestServerCORSPrivateNetwork(t *testing.T) {
t.Parallel()
srv := makeTestServer(t, `
cors:
enabled: true
allow_private_network: true`)
defer srv.Close()
req, err := http.NewRequest(http.MethodOptions, srv.URL, nil)
require.NoError(t, err)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
req.Header.Set("Access-Control-Request-Private-Network", "true")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusNoContent, resp.StatusCode)
require.Equal(t, "true", resp.Header.Get("Access-Control-Allow-Private-Network"))
}
func TestServerPartialUpdateOptions(t *testing.T) {
t.Parallel()
@@ -486,13 +510,21 @@ func TestServerPartialUpdateHonorsLocks(t *testing.T) {
func TestServerListingCharacters(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
contents := map[string][]byte{
"富/foo.txt": []byte("foo"),
"你好.txt": []byte("bar"),
"z*.txt": []byte("zbar"),
"foo.txt": []byte("foo"),
"🌹.txt": []byte("foo"),
})
}
expectedNames := []string{"foo.txt", "你好.txt", "富", "🌹.txt"}
if runtime.GOOS != "windows" {
// Asterisks are invalid in Windows filenames.
contents["z*.txt"] = []byte("zbar")
expectedNames = append(expectedNames, "z*.txt")
}
sort.Strings(expectedNames)
dir := makeTestDirectory(t, contents)
srv := makeTestServer(t, "directory: "+dir)
client := gowebdav.NewClient(srv.URL, "", "")
@@ -500,28 +532,21 @@ func TestServerListingCharacters(t *testing.T) {
// By default, reading permissions.
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 5)
require.Len(t, files, len(expectedNames))
names := []string{
files[0].Name(),
files[1].Name(),
files[2].Name(),
files[3].Name(),
files[4].Name(),
names := make([]string, len(files))
for i, file := range files {
names[i] = file.Name()
}
sort.Strings(names)
require.Equal(t, []string{
"foo.txt",
"z*.txt",
"你好.txt",
"富",
"🌹.txt",
}, names)
require.Equal(t, expectedNames, names)
data, err := client.Read("/z*.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("zbar"), data)
if runtime.GOOS != "windows" {
data, err := client.Read("/z*.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("zbar"), data)
}
}
func TestServerAuthentication(t *testing.T) {
@@ -652,9 +677,13 @@ users:
client := gowebdav.NewClient(srv.URL, "basic", "basic")
// A rule denying a path also hides it from the collection containing it.
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 5)
require.Len(t, files, 4)
for _, f := range files {
require.NotEqual(t, "c", f.Name())
}
err = client.Write("/foo.txt", []byte("new"), 0666)
require.NoError(t, err)
@@ -779,9 +808,13 @@ users:
client := gowebdav.NewClient(srv.URL, "basic", "basic")
// A rule denying a path also hides it from the collection containing it.
files, err := client.ReadDir("/prefix")
require.NoError(t, err)
require.Len(t, files, 5)
require.Len(t, files, 4)
for _, f := range files {
require.NotEqual(t, "c", f.Name())
}
err = client.Write("/prefix/foo.txt", []byte("new"), 0666)
require.NoError(t, err)
@@ -820,6 +853,129 @@ users:
require.ErrorContains(t, err, "403")
}
func TestServerMultiDirectories(t *testing.T) {
t.Parallel()
dirC := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
"folder/nested.txt": []byte("nested"),
"public/access/ok.txt": []byte("ok"),
})
dirD := makeTestDirectory(t, map[string][]byte{
"bar.txt": []byte("bar"),
})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: CRUD
directories:
- c: %s
- d: %s
`, dirC, dirD))
client := gowebdav.NewClient(srv.URL, "", "")
files, err := client.ReadDir("/")
require.NoError(t, err)
require.Len(t, files, 2)
require.Equal(t, "c", files[0].Name())
require.Equal(t, "d", files[1].Name())
data, err := client.Read("/c/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
data, err = client.Read("/d/bar.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("bar"), data)
err = client.Copy("/c/foo.txt", "/d/copied.txt", false)
require.NoError(t, err)
data, err = os.ReadFile(filepath.Join(dirD, "copied.txt"))
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
err = client.Rename("/c/foo.txt", "/d/moved.txt", false)
require.NoError(t, err)
require.NoFileExists(t, filepath.Join(dirC, "foo.txt"))
data, err = os.ReadFile(filepath.Join(dirD, "moved.txt"))
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
err = client.Rename("/d/bar.txt", "/d/renamed.txt", false)
require.NoError(t, err)
require.NoFileExists(t, filepath.Join(dirD, "bar.txt"))
data, err = os.ReadFile(filepath.Join(dirD, "renamed.txt"))
require.NoError(t, err)
require.EqualValues(t, []byte("bar"), data)
err = client.Rename("/c/folder", "/d/folder", false)
require.NoError(t, err)
require.NoDirExists(t, filepath.Join(dirC, "folder"))
data, err = os.ReadFile(filepath.Join(dirD, "folder", "nested.txt"))
require.NoError(t, err)
require.EqualValues(t, []byte("nested"), data)
require.ErrorContains(t, client.Remove("/c"), "405")
require.Error(t, client.Write("/c", []byte("blocked"), 0666))
require.ErrorContains(t, client.Rename("/d", "/c/d", false), "403")
}
func TestServerMultiDirectoriesRules(t *testing.T) {
t.Parallel()
dirC := makeTestDirectory(t, map[string][]byte{
"public/access/ok.txt": []byte("ok"),
})
dirD := makeTestDirectory(t, map[string][]byte{
"public/access/no.txt": []byte("no"),
})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: none
directories:
- c: %s
- d: %s
rules:
- path: /c/public/access/
permissions: R
`, dirC, dirD))
client := gowebdav.NewClient(srv.URL, "", "")
data, err := client.Read("/c/public/access/ok.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("ok"), data)
_, err = client.Read("/d/public/access/no.txt")
require.ErrorContains(t, err, "403")
_, err = client.Read("/public/access/ok.txt")
require.ErrorContains(t, err, "403")
}
func TestServerMultiDirectoriesPrefix(t *testing.T) {
t.Parallel()
dirC := makeTestDirectory(t, map[string][]byte{
"foo.txt": []byte("foo"),
})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: R
prefix: /prefix
directories:
- c: %s
`, dirC))
client := gowebdav.NewClient(srv.URL, "", "")
files, err := client.ReadDir("/prefix")
require.NoError(t, err)
require.Len(t, files, 1)
require.Equal(t, "c", files[0].Name())
data, err := client.Read("/prefix/c/foo.txt")
require.NoError(t, err)
require.EqualValues(t, []byte("foo"), data)
}
func TestServerPermissions(t *testing.T) {
t.Parallel()
@@ -887,3 +1043,548 @@ users:
require.ErrorContains(t, err, "403")
})
}
func TestServerRulesDotSegments(t *testing.T) {
t.Parallel()
// Sends a request with an unmodified request-target, since a WebDAV client
// would normalize the dot segments away before they reach the server.
do := func(t *testing.T, method, url string, header map[string]string) int {
t.Helper()
req, err := http.NewRequest(method, url, nil)
require.NoError(t, err)
for k, v := range header {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
return resp.StatusCode
}
makeServer := func(t *testing.T) (*httptest.Server, string) {
dir := makeTestDirectory(t, map[string][]byte{
"public/pub.txt": []byte("public"),
"secret/flag.txt": []byte("secret"),
"secret/keep.txt": []byte("keep"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- path: "/secret/"
permissions: none
`, dir))
t.Cleanup(srv.Close)
return srv, dir
}
t.Run("Source Path", func(t *testing.T) {
t.Parallel()
srv, _ := makeServer(t)
for _, path := range []string{
"/secret/flag.txt",
"/public/../secret/flag.txt",
"/public/%2e%2e/secret/flag.txt",
"/public/../secret/",
"/secret/.",
"/secret/%2e",
} {
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+path, nil), path)
}
require.Equal(t, http.StatusForbidden, do(t, "PUT", srv.URL+"/public/%2e%2e/secret/new.txt", nil))
require.Equal(t, http.StatusForbidden, do(t, "DELETE", srv.URL+"/public/%2e%2e/secret/keep.txt", nil))
// A request that resolves outside a rule must still succeed.
require.Equal(t, http.StatusOK, do(t, "GET", srv.URL+"/secret/../public/pub.txt", nil))
})
t.Run("Destination Header", func(t *testing.T) {
t.Parallel()
srv, _ := makeServer(t)
for _, destination := range []string{
srv.URL + "/public/%2e%2e/secret/moved.txt",
srv.URL + "/public/../secret/moved.txt",
"/public/%2e%2e/secret/moved.txt",
} {
code := do(t, "MOVE", srv.URL+"/public/pub.txt", map[string]string{
"Destination": destination,
"Overwrite": "T",
})
require.Equal(t, http.StatusForbidden, code, destination)
}
})
t.Run("Regex Rule", func(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"public/pub.txt": []byte("public"),
"secret/flag.txt": []byte("secret"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- regex: "^/secret/"
permissions: none
`, dir))
defer srv.Close()
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/secret/flag.txt", nil))
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/public/%2e%2e/secret/flag.txt", nil))
})
t.Run("Directory Mounts", func(t *testing.T) {
t.Parallel()
alpha := makeTestDirectory(t, map[string][]byte{"a.txt": []byte("a")})
beta := makeTestDirectory(t, map[string][]byte{"b.txt": []byte("b")})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: CRUD
directories:
- name: alpha
path: %s
- name: beta
path: %s
rules:
- path: "/beta/"
permissions: none
`, alpha, beta))
defer srv.Close()
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/beta/b.txt", nil))
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/alpha/%2e%2e/beta/b.txt", nil))
require.Equal(t, http.StatusOK, do(t, "GET", srv.URL+"/alpha/a.txt", nil))
})
t.Run("No Users", func(t *testing.T) {
t.Parallel()
srv, _ := makeServer(t)
// Without a users block no authentication runs, so the rule is the only
// access control there is.
require.Equal(t, http.StatusForbidden, do(t, "GET", srv.URL+"/public/%2e%2e/secret/flag.txt", nil))
})
}
func TestServerRulesBareCollection(t *testing.T) {
t.Parallel()
do := func(t *testing.T, method, url string) int {
t.Helper()
req, err := http.NewRequest(method, url, nil)
require.NoError(t, err)
req.Header.Set("Depth", "1")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
return resp.StatusCode
}
dir := makeTestDirectory(t, map[string][]byte{
"c/secret.txt": []byte("secret"),
"cd/open.txt": []byte("open"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- path: "/c/"
permissions: none
`, dir))
defer srv.Close()
// A rule written "/c/" must also cover the collection named without the
// trailing slash, otherwise the denied directory can be listed or deleted.
for _, path := range []string{"/c/", "/c", "/c/secret.txt"} {
require.Equal(t, http.StatusForbidden, do(t, "PROPFIND", srv.URL+path), path)
require.Equal(t, http.StatusForbidden, do(t, "DELETE", srv.URL+path), path)
}
// A sibling whose name merely starts with the same characters is unaffected.
require.Equal(t, http.StatusMultiStatus, do(t, "PROPFIND", srv.URL+"/cd"))
require.Equal(t, http.StatusOK, do(t, "GET", srv.URL+"/cd/open.txt"))
// A rule governs the collection it names, but must not grant access to it
// that would not otherwise exist: removing "/pub" acts on the root, which
// the global permissions still deny.
grantDir := makeTestDirectory(t, map[string][]byte{"pub/x.txt": []byte("x")})
grantSrv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: none
rules:
- path: "/pub/"
permissions: CRUD
`, grantDir))
defer grantSrv.Close()
require.Equal(t, http.StatusNoContent, do(t, "DELETE", grantSrv.URL+"/pub/x.txt"))
require.Equal(t, http.StatusForbidden, do(t, "DELETE", grantSrv.URL+"/pub"))
require.Equal(t, http.StatusForbidden, do(t, "PROPFIND", grantSrv.URL+"/pub"))
}
// doRequest sends a raw request, for what gowebdav does not expose directly.
// An empty username sends no credentials.
func doRequest(t *testing.T, method, url, username, password string, headers map[string]string, body string) (int, string) {
t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req, err := http.NewRequest(method, url, reader)
require.NoError(t, err)
if username != "" {
req.SetBasicAuth(username, password)
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
return resp.StatusCode, string(data)
}
const exclusiveWriteLock = `<?xml version="1.0" encoding="utf-8" ?>
<D:lockinfo xmlns:D="DAV:">
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:owner>tester</D:owner>
</D:lockinfo>`
// TestServerRulesShadowedCollection covers a broader rule shadowing one that
// names a collection: resolving "/data/secret" through "/data/" would skip the
// deny rule, leaving the collection listable, relocatable and removable.
func TestServerRulesShadowedCollection(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
config string
username string
}{
{
name: "path rule shadows",
config: `
permissions: none
rules:
- path: "/data/"
permissions: CRUD
- path: "/data/secret/"
permissions: none`,
},
{
name: "regex rule shadows",
config: `
permissions: none
rules:
- regex: "^/data/"
permissions: CRUD
- path: "/data/secret/"
permissions: none`,
},
{
// The shadowing rule need not sit next to the rule it shadows.
name: "appended global rule shadows",
username: "basic",
config: `
permissions: none
rules:
- path: "/data/"
permissions: CRUD
users:
- username: basic
password: basic
rulesBehavior: append
rules:
- path: "/data/secret/"
permissions: none`,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"data/secret/flag.txt": []byte("secret"),
})
srv := makeTestServer(t, fmt.Sprintf("directory: %s\n%s\n", dir, tc.config))
defer srv.Close()
do := func(method, path string, headers map[string]string) int {
t.Helper()
code, _ := doRequest(t, method, srv.URL+path, tc.username, "basic", headers, "")
return code
}
// Controls: the deny rule holds inside the collection, and for the
// collection named with its trailing slash.
require.Equal(t, http.StatusForbidden, do("GET", "/data/secret/flag.txt", nil))
require.Equal(t, http.StatusForbidden, do("PROPFIND", "/data/secret/", map[string]string{"Depth": "1"}))
// Named without the trailing slash it is the same collection.
require.Equal(t, http.StatusForbidden, do("PROPFIND", "/data/secret", map[string]string{"Depth": "1"}))
require.Equal(t, http.StatusForbidden, do("DELETE", "/data/secret", nil))
require.Equal(t, http.StatusForbidden, do("MOVE", "/data/secret", map[string]string{"Destination": srv.URL + "/data/exposed"}))
require.Equal(t, http.StatusForbidden, do("COPY", "/data/secret", map[string]string{"Destination": srv.URL + "/data/copied"}))
require.FileExists(t, filepath.Join(dir, "data", "secret", "flag.txt"))
require.NoFileExists(t, filepath.Join(dir, "data", "exposed", "flag.txt"))
require.NoFileExists(t, filepath.Join(dir, "data", "copied", "flag.txt"))
})
}
}
// TestServerRulesCollectionGrantedByEnclosingRule guards the other direction: a
// rule naming a collection must not lose access an enclosing rule grants.
func TestServerRulesCollectionGrantedByEnclosingRule(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{"data/sub/foo.txt": []byte("foo")})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: none
rules:
- path: "/data/"
permissions: CRUD
- path: "/data/sub/"
permissions: CRUD
`, dir))
defer srv.Close()
code, _ := doRequest(t, "PROPFIND", srv.URL+"/data/sub", "", "", map[string]string{"Depth": "1"}, "")
require.Equal(t, http.StatusMultiStatus, code)
}
// TestServerRulesRecursiveDescendants covers operations acting on a whole
// subtree, which authorizing only the requested path let reach denied
// descendants: reading, enumerating, relocating and destroying them.
func TestServerRulesRecursiveDescendants(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"secret/flag.txt": []byte("top secret"),
"secret/public-note.txt": []byte("note"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- path: "/secret/flag.txt"
permissions: none
`, dir))
defer srv.Close()
// Control: the rule holds when the request names the denied path.
code, _ := doRequest(t, "GET", srv.URL+"/secret/flag.txt", "", "", nil, "")
require.Equal(t, http.StatusForbidden, code)
// Enumerating the parent must not report the denied descendant.
code, body := doRequest(t, "PROPFIND", srv.URL+"/secret", "", "", map[string]string{"Depth": "infinity"}, "")
require.Equal(t, http.StatusMultiStatus, code)
require.NotContains(t, body, "flag.txt")
require.Contains(t, body, "public-note.txt")
// Copying the parent must leave the denied descendant behind, not carry it
// to a path no rule covers. The permitted sibling still copies.
code, _ = doRequest(t, "COPY", srv.URL+"/secret", "", "", map[string]string{"Destination": srv.URL + "/stolen", "Depth": "infinity"}, "")
require.Equal(t, http.StatusCreated, code)
code, _ = doRequest(t, "GET", srv.URL+"/stolen/flag.txt", "", "", nil, "")
require.Equal(t, http.StatusNotFound, code)
code, _ = doRequest(t, "GET", srv.URL+"/stolen/public-note.txt", "", "", nil, "")
require.Equal(t, http.StatusOK, code)
// MOVE and DELETE act on the subtree in one call, so they cannot be partial.
code, _ = doRequest(t, "MOVE", srv.URL+"/secret", "", "", map[string]string{"Destination": srv.URL + "/moved"}, "")
require.Equal(t, http.StatusForbidden, code)
code, _ = doRequest(t, "DELETE", srv.URL+"/secret", "", "", nil, "")
require.Equal(t, http.StatusForbidden, code)
require.FileExists(t, filepath.Join(dir, "secret", "flag.txt"))
}
// TestServerRulesRecursiveDescendantsMultiDir is the same defect across mounts,
// where copying out of one lands the denied file in another.
func TestServerRulesRecursiveDescendantsMultiDir(t *testing.T) {
t.Parallel()
dirA := makeTestDirectory(t, map[string][]byte{"secret/flag.txt": []byte("mount secret")})
dirB := makeTestDirectory(t, map[string][]byte{"keep.txt": []byte("keep")})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: CRUD
directories:
- name: alpha
path: %s
- name: beta
path: %s
rules:
- path: "/alpha/secret/flag.txt"
permissions: none
`, dirA, dirB))
defer srv.Close()
code, _ := doRequest(t, "COPY", srv.URL+"/alpha/secret", "", "", map[string]string{"Destination": srv.URL + "/beta/stolen", "Depth": "infinity"}, "")
require.Equal(t, http.StatusCreated, code)
code, _ = doRequest(t, "GET", srv.URL+"/beta/stolen/flag.txt", "", "", nil, "")
require.Equal(t, http.StatusNotFound, code)
code, _ = doRequest(t, "DELETE", srv.URL+"/alpha/secret", "", "", nil, "")
require.Equal(t, http.StatusForbidden, code)
require.FileExists(t, filepath.Join(dirA, "secret", "flag.txt"))
}
// TestServerLockRequiresWritePermission covers LOCK being authorized by any
// permission at all, which let a read-only user create a file by locking a
// missing path, and hold a write lock that blocks legitimate writers.
func TestServerLockRequiresWritePermission(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{"existing.txt": []byte("x")})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: none
users:
- username: reader
password: reader
permissions: R
- username: writer
password: writer
permissions: CRUD
`, dir))
defer srv.Close()
lock := func(username, path string) int {
t.Helper()
code, _ := doRequest(t, "LOCK", srv.URL+path, username, username,
map[string]string{"Timeout": "Infinite", "Content-Type": "application/xml"}, exclusiveWriteLock)
return code
}
// A read-only user may neither lock a resource into existence nor reserve one.
require.Equal(t, http.StatusForbidden, lock("reader", "/created-by-lock.txt"))
require.NoFileExists(t, filepath.Join(dir, "created-by-lock.txt"))
require.Equal(t, http.StatusForbidden, lock("reader", "/existing.txt"))
// Reading is unaffected.
code, _ := doRequest(t, "GET", srv.URL+"/existing.txt", "reader", "reader", nil, "")
require.Equal(t, http.StatusOK, code)
// A user who can write still locks as before.
require.Equal(t, http.StatusOK, lock("writer", "/existing.txt"))
require.Equal(t, http.StatusCreated, lock("writer", "/new.txt"))
}
// TestServerRulesCaseInsensitiveFilesystem covers a rule being evaded by asking
// for a differently cased spelling of the same file.
func TestServerRulesCaseInsensitiveFilesystem(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{"secret/flag.txt": []byte("secret")})
if _, err := os.Stat(filepath.Join(dir, "SECRET", "flag.txt")); err != nil {
t.Skip("backing file system distinguishes path case")
}
// A regex rule has to follow the file system too, or it is evaded the same
// way while looking like it denies the path.
for _, rule := range []string{`path: "/secret/"`, `regex: "^/secret/"`} {
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- %s
permissions: none
`, dir, rule))
for _, path := range []string{"/secret/flag.txt", "/SECRET/flag.txt", "/Secret/flag.txt"} {
code, _ := doRequest(t, "GET", srv.URL+path, "", "", nil, "")
require.Equal(t, http.StatusForbidden, code, rule, path)
code, _ = doRequest(t, "DELETE", srv.URL+path, "", "", nil, "")
require.Equal(t, http.StatusForbidden, code, rule, path)
}
srv.Close()
}
require.FileExists(t, filepath.Join(dir, "secret", "flag.txt"))
}
func TestServerRulesEmptyPrefixDestination(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"public/a.txt": []byte("a"),
"public/b.txt": []byte("b"),
"secret/x.txt": []byte("secret"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
prefix: ""
permissions: CRUD
rules:
- path: "/secret/"
permissions: none
`, dir))
defer srv.Close()
// RFC 4918 has Destination as an absolute URI, so the host must not end up
// in the value the rules are matched against.
for name, destination := range map[string]string{
"absolute": srv.URL + "/secret/moved.txt",
"bare path": "/secret/moved.txt",
"dot": srv.URL + "/public/%2e%2e/secret/moved.txt",
} {
req, err := http.NewRequest("MOVE", srv.URL+"/public/a.txt", nil)
require.NoError(t, err)
req.Header.Set("Destination", destination)
req.Header.Set("Overwrite", "T")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusForbidden, resp.StatusCode, name)
}
// A destination outside any rule must still work.
req, err := http.NewRequest("MOVE", srv.URL+"/public/b.txt", nil)
require.NoError(t, err)
req.Header.Set("Destination", srv.URL+"/public/moved.txt")
req.Header.Set("Overwrite", "T")
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
require.Equal(t, http.StatusCreated, resp.StatusCode)
}
+55 -7
View File
@@ -1,6 +1,7 @@
package lib
import (
"path"
"path/filepath"
"time"
@@ -9,27 +10,74 @@ import (
var _ webdav.LockSystem = &lockSystem{}
// LockSystem wraps a [webdav.LockSystem] with a root directory, allowing
// to reuse the same [webdav.LockSystem] for multiple users with different base
// directories, meaning we can correctly lock the files across different users.
// lockSystem wraps a [webdav.LockSystem], mapping virtual request names to the
// real backing paths via resolve. This allows reusing the same
// [webdav.LockSystem] for multiple users with different base directories,
// meaning we can correctly lock the files across different users.
type lockSystem struct {
webdav.LockSystem
directory string
resolve func(name string) (string, error)
}
// newLockSystem returns a lockSystem for a single-directory user, resolving
// names relative to directory.
func newLockSystem(ls webdav.LockSystem, directory string) *lockSystem {
return &lockSystem{
LockSystem: ls,
resolve: func(name string) (string, error) {
// Lock names share a slash-separated namespace across users, even
// on Windows where filepath.Join would emit backslashes and break
// descendant-lock matching in the underlying LockSystem.
return path.Join(filepath.ToSlash(directory), name), nil
},
}
}
// newMultiDirLockSystem returns a lockSystem for a multi-directory user,
// resolving names against the real backing path of each mount.
func newMultiDirLockSystem(ls webdav.LockSystem, mounts DirectoryMounts) *lockSystem {
return &lockSystem{
LockSystem: ls,
resolve: func(name string) (string, error) {
if cleanName(name) == "/" {
return "/", nil
}
mount, rest, err := multiDir{mounts: mounts}.resolve(name)
if err != nil {
return "", err
}
// filePath returns an OS-native path for real file operations; the
// lock namespace must stay slash-separated so descendant locks match
// on Windows.
return filepath.ToSlash(mount.filePath(rest)), nil
},
}
}
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
if name0 != "" {
name0 = filepath.Join(l.directory, name0)
name0, err = l.resolve(name0)
if err != nil {
return nil, err
}
}
if name1 != "" {
name1 = filepath.Join(l.directory, name1)
name1, err = l.resolve(name1)
if err != nil {
return nil, err
}
}
return l.LockSystem.Confirm(now, name0, name1, conditions...)
}
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
details.Root = filepath.Join(l.directory, details.Root)
details.Root, err = l.resolve(details.Root)
if err != nil {
return "", err
}
return l.LockSystem.Create(now, details)
}
+79
View File
@@ -0,0 +1,79 @@
package lib
import (
"path"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/net/webdav"
)
func TestLockSystemRootLockProtectsDescendants(t *testing.T) {
t.Parallel()
locks := newLockSystem(webdav.NewMemLS(), filepath.Join(t.TempDir(), "nested"))
now := time.Now()
token, err := locks.Create(now, webdav.LockDetails{
Root: "/",
Duration: time.Minute,
})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, locks.Unlock(time.Now(), token))
})
_, err = locks.Create(now, webdav.LockDetails{
Root: "/child.txt",
Duration: time.Minute,
ZeroDepth: true,
})
require.ErrorIs(t, err, webdav.ErrLocked)
}
func TestLockSystemSharesLocksAcrossNestedUserDirectories(t *testing.T) {
t.Parallel()
shared := webdav.NewMemLS()
parentDirectory := t.TempDir()
childDirectory := filepath.Join(parentDirectory, "child")
parent := newLockSystem(shared, parentDirectory)
child := newLockSystem(shared, childDirectory)
now := time.Now()
token, err := parent.Create(now, webdav.LockDetails{
Root: "/",
Duration: time.Minute,
})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, parent.Unlock(time.Now(), token))
})
_, err = child.Create(now, webdav.LockDetails{
Root: "/file.txt",
Duration: time.Minute,
ZeroDepth: true,
})
require.ErrorIs(t, err, webdav.ErrLocked)
// The lock key is slash-separated on every platform, so the child's file
// nests under the parent's root lock rather than diverging on Windows.
key, err := child.resolve("/file.txt")
require.NoError(t, err)
require.Equal(t, path.Join(filepath.ToSlash(childDirectory), "file.txt"), key)
}
func TestMultiDirLockSystemUsesSlashSeparatedKeys(t *testing.T) {
t.Parallel()
mounts := DirectoryMounts{{Name: "docs", Path: filepath.Join(t.TempDir(), "docs")}}
locks := newMultiDirLockSystem(webdav.NewMemLS(), mounts)
key, err := locks.resolve("/docs/report.txt")
require.NoError(t, err)
require.Equal(t, path.Join(filepath.ToSlash(mounts[0].Path), "report.txt"), key)
require.NotContains(t, key, "\\")
}
+384
View File
@@ -0,0 +1,384 @@
package lib
import (
"context"
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"runtime"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/net/webdav"
)
var _ webdav.FileSystem = multiDir{}
const windowsErrorNotSameDevice = syscall.Errno(17)
type multiDir struct {
mounts DirectoryMounts
noSniff bool
}
func (m multiDir) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
mount, rest, err := m.resolve(name)
if err != nil {
return err
}
if rest == "/" {
return os.ErrExist
}
return mount.dir(m.noSniff).Mkdir(ctx, rest, perm)
}
func (m multiDir) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
if cleanName(name) == "/" {
if writeFlag(flag) {
return nil, os.ErrPermission
}
return &multiDirRootFile{
entries: m.rootEntries(ctx),
info: virtualDirInfo{name: "/"},
}, nil
}
mount, rest, err := m.resolve(name)
if err != nil {
return nil, err
}
if rest == "/" && writeFlag(flag) {
return nil, os.ErrPermission
}
file, err := mount.dir(m.noSniff).OpenFile(ctx, rest, flag, perm)
if err != nil {
return nil, err
}
if rest == "/" {
return mountRootFile{File: file, name: mount.Name}, nil
}
return file, nil
}
func (m multiDir) RemoveAll(ctx context.Context, name string) error {
if cleanName(name) == "/" {
return os.ErrInvalid
}
mount, rest, err := m.resolve(name)
if err != nil {
return err
}
if rest == "/" {
return os.ErrInvalid
}
return mount.dir(m.noSniff).RemoveAll(ctx, rest)
}
func (m multiDir) Rename(ctx context.Context, oldName, newName string) error {
oldMount, oldRest, err := m.resolve(oldName)
if err != nil {
return err
}
newMount, newRest, err := m.resolve(newName)
if err != nil {
return err
}
if oldRest == "/" || newRest == "/" {
return os.ErrInvalid
}
if oldMount.Name == newMount.Name {
return oldMount.dir(m.noSniff).Rename(ctx, oldRest, newRest)
}
oldPath := oldMount.filePath(oldRest)
newPath := newMount.filePath(newRest)
if err := os.Rename(oldPath, newPath); err != nil {
if isCrossDeviceError(err) {
return renameAcrossMount(oldPath, newPath)
}
return err
}
return nil
}
func renameAcrossMount(oldPath, newPath string) error {
info, err := os.Lstat(oldPath)
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(oldPath)
if err != nil {
return err
}
if err := os.Symlink(target, newPath); err != nil {
return err
}
return os.Remove(oldPath)
}
if info.Mode().IsRegular() {
if err := copyRegularFile(oldPath, newPath); err != nil {
return err
}
} else {
source, err := fs.Sub(os.DirFS(filepath.Dir(oldPath)), filepath.Base(oldPath))
if err != nil {
return err
}
if err := os.CopyFS(newPath, source); err != nil {
_ = os.RemoveAll(newPath)
return err
}
}
if err := copyMetadata(oldPath, newPath); err != nil {
_ = os.RemoveAll(newPath)
return err
}
return os.RemoveAll(oldPath)
}
func copyRegularFile(oldPath, newPath string) error {
source, err := os.Open(oldPath)
if err != nil {
return err
}
target, err := os.OpenFile(newPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
if err != nil {
_ = source.Close()
return err
}
_, copyErr := io.Copy(target, source)
copyErr = errors.Join(copyErr, target.Close(), source.Close())
if copyErr != nil {
_ = os.Remove(newPath)
return copyErr
}
return nil
}
func copyMetadata(oldPath, newPath string) error {
return filepath.Walk(oldPath, func(name string, info os.FileInfo, err error) error {
if err != nil || info.Mode()&os.ModeSymlink != 0 {
return err
}
rel, err := filepath.Rel(oldPath, name)
if err != nil {
return err
}
newName := filepath.Join(newPath, rel)
if err := os.Chmod(newName, info.Mode().Perm()); err != nil {
return err
}
return os.Chtimes(newName, info.ModTime(), info.ModTime())
})
}
func isCrossDeviceError(err error) bool {
return errors.Is(err, syscall.EXDEV) || runtime.GOOS == "windows" && errors.Is(err, windowsErrorNotSameDevice)
}
func (m multiDir) Stat(ctx context.Context, name string) (os.FileInfo, error) {
if cleanName(name) == "/" {
return virtualDirInfo{name: "/"}, nil
}
mount, rest, err := m.resolve(name)
if err != nil {
return nil, err
}
info, err := mount.dir(m.noSniff).Stat(ctx, rest)
if err != nil {
return nil, err
}
if rest == "/" {
return namedFileInfo{FileInfo: info, name: mount.Name}, nil
}
return info, nil
}
func (m multiDir) resolve(name string) (DirectoryMount, string, error) {
name = cleanName(name)
if name == "/" {
return DirectoryMount{}, "", os.ErrInvalid
}
trimmed := strings.TrimPrefix(name, "/")
mountName, rest, _ := strings.Cut(trimmed, "/")
for _, mount := range m.mounts {
if mount.Name == mountName {
if rest == "" {
return mount, "/", nil
}
return mount, "/" + rest, nil
}
}
return DirectoryMount{}, "", os.ErrNotExist
}
func (m multiDir) rootEntries(ctx context.Context) []os.FileInfo {
entries := make([]os.FileInfo, 0, len(m.mounts))
for _, mount := range m.mounts {
info, err := mount.dir(m.noSniff).Stat(ctx, "/")
if err != nil {
entries = append(entries, virtualDirInfo{name: mount.Name})
continue
}
entries = append(entries, namedFileInfo{FileInfo: info, name: mount.Name})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
return entries
}
func (d DirectoryMount) dir(noSniff bool) Dir {
return Dir{
Dir: webdav.Dir(d.Path),
noSniff: noSniff,
}
}
func (d DirectoryMount) filePath(name string) string {
return filepath.Join(d.Path, filepath.FromSlash(strings.TrimPrefix(name, "/")))
}
func cleanName(name string) string {
if name == "" || !strings.HasPrefix(name, "/") {
name = "/" + name
}
return path.Clean(name)
}
func writeFlag(flag int) bool {
return flag&(os.O_WRONLY|os.O_RDWR|os.O_CREATE|os.O_TRUNC|os.O_APPEND) != 0
}
type multiDirRootFile struct {
entries []os.FileInfo
info os.FileInfo
offset int
}
func (f *multiDirRootFile) Close() error {
return nil
}
func (f *multiDirRootFile) Read([]byte) (int, error) {
return 0, io.EOF
}
func (f *multiDirRootFile) Seek(offset int64, whence int) (int64, error) {
var next int64
switch whence {
case io.SeekStart:
next = offset
case io.SeekCurrent:
next = int64(f.offset) + offset
case io.SeekEnd:
next = int64(len(f.entries)) + offset
default:
return 0, os.ErrInvalid
}
if next < 0 {
return 0, os.ErrInvalid
}
f.offset = int(next)
return next, nil
}
func (f *multiDirRootFile) Readdir(count int) ([]os.FileInfo, error) {
if count <= 0 {
entries := f.entries[f.offset:]
f.offset = len(f.entries)
return entries, nil
}
if f.offset >= len(f.entries) {
return nil, io.EOF
}
end := f.offset + count
if end > len(f.entries) {
end = len(f.entries)
}
entries := f.entries[f.offset:end]
f.offset = end
return entries, nil
}
func (f *multiDirRootFile) Stat() (os.FileInfo, error) {
return f.info, nil
}
func (f *multiDirRootFile) Write([]byte) (int, error) {
return 0, os.ErrPermission
}
type mountRootFile struct {
webdav.File
name string
}
func (f mountRootFile) Stat() (os.FileInfo, error) {
info, err := f.File.Stat()
if err != nil {
return nil, err
}
return namedFileInfo{FileInfo: info, name: f.name}, nil
}
type namedFileInfo struct {
os.FileInfo
name string
}
func (i namedFileInfo) Name() string {
return i.name
}
type virtualDirInfo struct {
name string
}
func (i virtualDirInfo) Name() string {
return i.name
}
func (i virtualDirInfo) Size() int64 {
return 0
}
func (i virtualDirInfo) Mode() os.FileMode {
return os.ModeDir | 0555
}
func (i virtualDirInfo) ModTime() time.Time {
return time.Time{}
}
func (i virtualDirInfo) IsDir() bool {
return true
}
func (i virtualDirInfo) Sys() any {
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package lib
import (
"os"
"path/filepath"
"runtime"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestRenameAcrossMount(t *testing.T) {
source := makeTestDirectory(t, map[string][]byte{
"file.txt": []byte("cross mount"),
"folder/empty": nil,
"folder/nested/file.txt": []byte("nested"),
})
target := t.TempDir()
sourceFile := filepath.Join(source, "file.txt")
modTime := time.Date(2020, time.January, 2, 3, 4, 5, 0, time.UTC)
require.NoError(t, os.Chmod(sourceFile, 0600))
require.NoError(t, os.Chtimes(sourceFile, modTime, modTime))
require.NoError(t, renameAcrossMount(sourceFile, filepath.Join(target, "file.txt")))
require.NoFileExists(t, filepath.Join(source, "file.txt"))
data, err := os.ReadFile(filepath.Join(target, "file.txt"))
require.NoError(t, err)
require.Equal(t, []byte("cross mount"), data)
info, err := os.Stat(filepath.Join(target, "file.txt"))
require.NoError(t, err)
if runtime.GOOS != "windows" {
require.Equal(t, os.FileMode(0600), info.Mode().Perm())
}
require.WithinDuration(t, modTime, info.ModTime(), time.Second)
require.NoError(t, renameAcrossMount(filepath.Join(source, "folder"), filepath.Join(target, "folder")))
require.NoDirExists(t, filepath.Join(source, "folder"))
require.DirExists(t, filepath.Join(target, "folder", "empty"))
data, err = os.ReadFile(filepath.Join(target, "folder", "nested", "file.txt"))
require.NoError(t, err)
require.Equal(t, []byte("nested"), data)
}
func TestRenameAcrossMountPreservesSymlink(t *testing.T) {
source := makeTestDirectory(t, map[string][]byte{
"file.txt": []byte("target"),
})
oldPath := filepath.Join(source, "link.txt")
if err := os.Symlink("file.txt", oldPath); err != nil {
t.Skipf("symbolic links are unavailable: %v", err)
}
newPath := filepath.Join(t.TempDir(), "link.txt")
require.NoError(t, renameAcrossMount(oldPath, newPath))
info, err := os.Lstat(newPath)
require.NoError(t, err)
require.NotZero(t, info.Mode()&os.ModeSymlink)
target, err := os.Readlink(newPath)
require.NoError(t, err)
require.Equal(t, "file.txt", target)
}
func TestIsCrossDeviceError(t *testing.T) {
err := syscall.EXDEV
if runtime.GOOS == "windows" {
err = windowsErrorNotSameDevice
}
require.True(t, isCrossDeviceError(err))
require.False(t, isCrossDeviceError(os.ErrPermission))
}
+7 -7
View File
@@ -48,7 +48,7 @@ func writePartialUpdateError(w http.ResponseWriter, err error, fallbackStatus in
func (u *handlerUser) handleOptions(w http.ResponseWriter, r *http.Request, reqPath string) {
allow := "OPTIONS, LOCK, PUT, MKCOL, PATCH"
if fi, err := u.FileSystem.Stat(r.Context(), reqPath); err == nil {
if fi, err := u.fs.Stat(r.Context(), reqPath); err == nil {
if fi.IsDir() {
allow = "OPTIONS, LOCK, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND"
} else {
@@ -97,7 +97,7 @@ func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request
defer release()
ctx := r.Context()
fi, statErr := u.FileSystem.Stat(ctx, reqPath)
fi, statErr := u.fs.Stat(ctx, reqPath)
exists := statErr == nil
if statErr != nil && !os.IsNotExist(statErr) {
http.Error(w, statErr.Error(), http.StatusMethodNotAllowed)
@@ -157,7 +157,7 @@ func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request
if !exists {
flag |= os.O_CREATE
}
f, err := u.FileSystem.OpenFile(ctx, reqPath, flag, 0666)
f, err := u.fs.OpenFile(ctx, reqPath, flag, 0666)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, err.Error(), http.StatusConflict)
@@ -361,7 +361,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
hdr := r.Header.Get("If")
if hdr == "" {
now := time.Now()
token, err := u.LockSystem.Create(now, webdav.LockDetails{
token, err := u.handler.LockSystem.Create(now, webdav.LockDetails{
Root: src,
Duration: -1,
ZeroDepth: true,
@@ -373,7 +373,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
return nil, http.StatusInternalServerError, err
}
return func() {
_ = u.LockSystem.Unlock(now, token)
_ = u.handler.LockSystem.Unlock(now, token)
}, 0, nil
}
@@ -393,7 +393,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
if parsedURL.Host != r.Host {
continue
}
lsrc, err = stripPartialPrefix(parsedURL.Path, u.Prefix)
lsrc, err = stripPartialPrefix(parsedURL.Path, u.handler.Prefix)
if err != nil {
return nil, http.StatusNotFound, err
}
@@ -401,7 +401,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
lsrc = src
}
}
release, err = u.LockSystem.Confirm(time.Now(), lsrc, "", l.conditions...)
release, err = u.handler.LockSystem.Confirm(time.Now(), lsrc, "", l.conditions...)
if errors.Is(err, webdav.ErrConfirmationFailed) {
continue
}
+161 -25
View File
@@ -26,15 +26,41 @@ func (r *Rule) Validate() error {
return nil
}
// Matches checks if [Rule] matches the given path.
func (r *Rule) Matches(path string) bool {
// Matches checks if [Rule] matches the given path. When caseInsensitive is set
// the backing file system ignores case, so this must too. A regex is tried
// against the folded path as well as the path as written, which only widens it
// to spellings naming the same file.
func (r *Rule) Matches(path string, caseInsensitive bool) bool {
if r.Regex != nil {
if caseInsensitive {
return r.Regex.MatchString(path) || r.Regex.MatchString(foldPath(path))
}
return r.Regex.MatchString(path)
}
if caseInsensitive {
return strings.HasPrefix(foldPath(path), foldPath(r.Path))
}
return strings.HasPrefix(path, r.Path)
}
// matchesCollection checks if [Rule] names path as the collection it governs,
// such as a rule for "/c/" and a request for "/c". Regex rules are matched
// literally and are not considered here.
func (r *Rule) matchesCollection(path string, caseInsensitive bool) bool {
if r.Regex != nil || !strings.HasSuffix(r.Path, "/") {
return false
}
if caseInsensitive {
return foldPath(path) == foldPath(strings.TrimSuffix(r.Path, "/"))
}
return path == strings.TrimSuffix(r.Path, "/")
}
type RulesBehavior string
const (
@@ -44,46 +70,83 @@ const (
type UserPermissions struct {
Directory string
Directories DirectoryMounts
Permissions Permissions
Rules []*Rule
RulesBehavior RulesBehavior
directoryExplicit bool
directoriesExplicit bool
useDirectories bool
caseInsensitive bool
}
type DirectoryMount struct {
Name string
Path string
}
type DirectoryMounts []DirectoryMount
// Allowed checks if the user has permission to access a directory/file
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
// For COPY and MOVE requests, we first check the permissions for the destination
// path. As soon as a rule matches and does not allow the operation at the destination,
// we fail immediately. If no rule matches, we check the global permissions.
if r.method == "COPY" || r.method == "MOVE" {
dst := r.destination
ruleMatched := false
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(dst) {
ruleMatched = true
if !p.Rules[i].Permissions.AllowedDestination(r, fileExists) {
return false
}
// Only check the first rule that matches, similarly to the source rules.
break
}
}
if !ruleMatched && !p.Permissions.AllowedDestination(r, fileExists) {
if !p.allowedAt(r.destination, func(perms Permissions) bool {
return perms.AllowedDestination(r, fileExists)
}) {
return false
}
}
// Go through rules beginning from the last one, and check the permissions at
// the source. The first matched rule returns.
return p.allowedAt(r.path, func(perms Permissions) bool {
return perms.Allowed(r, fileExists)
})
}
// allowedAt resolves the permissions that govern path and applies check to them.
func (p UserPermissions) allowedAt(path string, check func(Permissions) bool) bool {
// Go through rules beginning from the last one. The first matched rule returns.
// Both senses of matching are tested per rule: in separate passes a broader
// rule would return first and shadow the narrower one naming the collection.
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].Matches(r.path) {
return p.Rules[i].Permissions.Allowed(r, fileExists)
if p.Rules[i].Matches(path, p.caseInsensitive) {
return check(p.Rules[i].Permissions)
}
// A rule written with a trailing slash also governs the collection it names,
// so a rule for "/c/" cannot be evaded by asking for "/c". Such a request acts
// on an entry of the parent collection, so it needs those permissions too: the
// rule can restrict the collection, not grant access that would not exist.
if p.Rules[i].matchesCollection(path, p.caseInsensitive) {
if !check(p.Rules[i].Permissions) {
return false
}
// Through the rules, not the global permissions alone, which would deny
// a collection that an enclosing rule grants.
if parent := parentCollection(path); parent != path {
return p.allowedAt(parent, check)
}
return check(p.Permissions)
}
}
return p.Permissions.Allowed(r, fileExists)
return check(p.Permissions)
}
// parentCollection returns the collection containing path, such as "/data/" for
// "/data/sub", or "/" for a top-level entry. That bounds allowedAt at the root.
func parentCollection(p string) string {
i := strings.LastIndex(strings.TrimSuffix(p, "/"), "/")
if i <= 0 {
return "/"
}
return p[:i+1]
}
func (p *UserPermissions) Validate() error {
@@ -94,6 +157,14 @@ func (p *UserPermissions) Validate() error {
return fmt.Errorf("invalid permissions: %w", err)
}
if p.useDirectories || len(p.Directories) > 0 {
if err := (&p.Directories).Validate(); err != nil {
return fmt.Errorf("invalid permissions: %w", err)
}
}
p.caseInsensitive = p.hasCaseInsensitiveBacking()
for _, r := range p.Rules {
if err := r.Validate(); err != nil {
return fmt.Errorf("invalid permissions: %w", err)
@@ -110,6 +181,63 @@ func (p *UserPermissions) Validate() error {
return nil
}
// hasCaseInsensitiveBacking reports whether any backing directory resolves names
// regardless of case. Mounts spread over volumes that differ all fold, which
// keeps deny rules effective on the case-insensitive ones.
func (p *UserPermissions) hasCaseInsensitiveBacking() bool {
if p.useDirectories || len(p.Directories) > 0 {
for _, mount := range p.Directories {
if caseInsensitiveFS(mount.Path) {
return true
}
}
return false
}
return caseInsensitiveFS(p.Directory)
}
func (d *DirectoryMounts) Validate() error {
names := map[string]struct{}{}
for i := range *d {
mount := &(*d)[i]
if mount.Path == "" {
return errors.New("invalid directories: path must be defined")
}
path, err := filepath.Abs(mount.Path)
if err != nil {
return fmt.Errorf("invalid directories: %w", err)
}
mount.Path = path
if mount.Name == "" {
mount.Name = filepath.Base(path)
}
if !validDirectoryMountName(mount.Name) {
return fmt.Errorf("invalid directories: invalid mount name %q", mount.Name)
}
if _, ok := names[mount.Name]; ok {
return fmt.Errorf("invalid directories: duplicate mount name %q", mount.Name)
}
names[mount.Name] = struct{}{}
}
return nil
}
func validDirectoryMountName(name string) bool {
if name == "" || name == "." || name == ".." {
return false
}
return !strings.ContainsAny(name, `/\`)
}
type Permissions struct {
Create bool
Read bool
@@ -164,8 +292,16 @@ func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
return p.Read && p.Delete
case "DELETE":
return p.Delete
case "LOCK", "UNLOCK":
return p.Create || p.Read || p.Update || p.Delete
case "LOCK":
// A lock is write-class: it reserves the resource against other writers,
// and locking a path that does not exist creates it.
if fileExists(r.path) {
return p.Update
} else {
return p.Create
}
case "UNLOCK":
return p.Create || p.Update
default:
return false
}
+158
View File
@@ -0,0 +1,158 @@
package lib
import (
"context"
"errors"
"io"
"os"
"path"
"golang.org/x/net/webdav"
)
var _ webdav.FileSystem = permissionsFS{}
// permissionsFS wraps a [webdav.FileSystem] so directory listings only report
// entries the user may read. PROPFIND and COPY reach descendants by enumerating
// through the file system, so rules have to be applied as those listings are produced.
//
// The wrapped file system is a named field rather than an embedded one on
// purpose. allowedThroughout has to read unfiltered listings, and a promoted
// OpenFile would make walking the filtered view by accident a one-character
// change that authorizes everything without failing any obvious way.
type permissionsFS struct {
fs webdav.FileSystem
perms UserPermissions
}
func (f permissionsFS) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
return f.fs.Mkdir(ctx, name, perm)
}
func (f permissionsFS) RemoveAll(ctx context.Context, name string) error {
return f.fs.RemoveAll(ctx, name)
}
func (f permissionsFS) Rename(ctx context.Context, oldName, newName string) error {
return f.fs.Rename(ctx, oldName, newName)
}
func (f permissionsFS) Stat(ctx context.Context, name string) (os.FileInfo, error) {
return f.fs.Stat(ctx, name)
}
func (f permissionsFS) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
file, err := f.fs.OpenFile(ctx, name, flag, perm)
if err != nil {
return nil, err
}
// The handler strips its prefix first, which for the default prefix "/" also
// drops the leading slash. Rules are written with one.
return &permissionsFile{File: file, name: cleanPath(name), perms: f.perms}, nil
}
type permissionsFile struct {
webdav.File
name string
perms UserPermissions
}
func (f *permissionsFile) Readdir(count int) ([]os.FileInfo, error) {
if count <= 0 {
fis, err := f.File.Readdir(count)
if err != nil {
return nil, err
}
return f.readable(fis), nil
}
// A positive count asks for that many entries, so keep reading until that
// many survive filtering: a short read would look like the end of the listing.
var entries []os.FileInfo
for len(entries) < count {
fis, err := f.File.Readdir(count - len(entries))
if err != nil {
if len(entries) > 0 && errors.Is(err, io.EOF) {
return entries, nil
}
return nil, err
}
if len(fis) == 0 {
break
}
entries = append(entries, f.readable(fis)...)
}
return entries, nil
}
// readable returns the entries whose path the user is allowed to read.
func (f *permissionsFile) readable(fis []os.FileInfo) []os.FileInfo {
allowed := make([]os.FileInfo, 0, len(fis))
for _, fi := range fis {
if f.perms.allowedAt(path.Join(f.name, fi.Name()), func(p Permissions) bool {
return p.Read
}) {
allowed = append(allowed, fi)
}
}
return allowed
}
// allowedThroughout reports whether check holds for every descendant of name.
// Rename and RemoveAll act on a subtree in one call without consulting the file
// system per descendant, so MOVE and DELETE need this before dispatching.
//
// It reads f.fs directly rather than the [permissionsFS] view: the latter omits
// the very entries this needs to refuse on.
func (f permissionsFS) allowedThroughout(ctx context.Context, name string, check func(Permissions) bool) (bool, error) {
info, err := f.fs.Stat(ctx, name)
if err != nil {
if os.IsNotExist(err) {
// Nothing to walk; the request fails later on its own terms.
return true, nil
}
return false, err
}
if !info.IsDir() {
return true, nil
}
file, err := f.fs.OpenFile(ctx, name, os.O_RDONLY, 0)
if err != nil {
return false, err
}
entries, err := file.Readdir(-1)
err = errors.Join(err, file.Close())
if err != nil {
return false, err
}
for _, entry := range entries {
child := path.Join(name, entry.Name())
if !f.perms.allowedAt(child, check) {
return false, nil
}
if entry.IsDir() {
ok, err := f.allowedThroughout(ctx, child, check)
if !ok || err != nil {
return ok, err
}
}
}
return true, nil
}
+115
View File
@@ -0,0 +1,115 @@
package lib
import (
"path/filepath"
"regexp"
"testing"
"github.com/stretchr/testify/require"
)
func TestRuleMatchesFolding(t *testing.T) {
t.Parallel()
rule := &Rule{Path: "/pub/"}
// Exact comparison: on a case-sensitive file system "/PUB/" is a different
// directory, which a rule granting "/pub/" must not reach.
require.True(t, rule.Matches("/pub/x.txt", false))
require.False(t, rule.Matches("/PUB/x.txt", false))
require.False(t, rule.Matches("/Pub/x.txt", false))
// Folded they name one directory, so the rule governs both.
require.True(t, rule.Matches("/pub/x.txt", true))
require.True(t, rule.Matches("/PUB/x.txt", true))
require.True(t, rule.Matches("/Pub/x.txt", true))
// A sibling merely starting with the same characters stays unaffected.
require.False(t, rule.Matches("/public/x.txt", true))
}
func TestRuleMatchesNormalization(t *testing.T) {
t.Parallel()
const (
nfc = "/café/" // café, composed
nfd = "/café/" // café, decomposed
)
rule := &Rule{Path: nfc}
// A file system ignoring case treats these spellings as one file too, so
// folding has to normalize or the rule is evaded by retyping it.
require.True(t, rule.Matches(nfd+"flag.txt", true))
require.False(t, rule.Matches(nfd+"flag.txt", false))
}
func TestRuleMatchesCollectionFolding(t *testing.T) {
t.Parallel()
rule := &Rule{Path: "/c/"}
require.True(t, rule.matchesCollection("/c", false))
require.False(t, rule.matchesCollection("/C", false))
require.True(t, rule.matchesCollection("/C", true))
// A rule without a trailing slash names a resource, not a collection.
require.False(t, (&Rule{Path: "/c"}).matchesCollection("/c", false))
}
func TestParentCollection(t *testing.T) {
t.Parallel()
require.Equal(t, "/data/", parentCollection("/data/sub"))
require.Equal(t, "/data/", parentCollection("/data/sub/"))
require.Equal(t, "/data/sub/", parentCollection("/data/sub/leaf.txt"))
require.Equal(t, "/", parentCollection("/pub"))
require.Equal(t, "/", parentCollection("/"))
}
func TestFlipCase(t *testing.T) {
t.Parallel()
// flipCase builds a path to stat, so it is separated the way the running
// system separates paths, not the way request paths are.
srv := func(name string) string {
return filepath.Join("/srv", name)
}
alt, ok := flipCase(srv("dav"))
require.True(t, ok)
require.Equal(t, srv("Dav"), alt)
alt, ok = flipCase(srv("DAV"))
require.True(t, ok)
require.Equal(t, srv("dAV"), alt)
// Only the first letter flips, so a name whose case mapping does not
// round-trip is left alone rather than changed by more than its case.
alt, ok = flipCase(srv("ıstanbul"))
require.True(t, ok)
require.Equal(t, srv("ıStanbul"), alt)
// Nothing to flip.
_, ok = flipCase(srv("001"))
require.False(t, ok)
}
func TestRuleMatchesRegexFolding(t *testing.T) {
t.Parallel()
rule := &Rule{Regex: regexp.MustCompile("^/secret/")}
require.True(t, rule.Matches("/secret/flag.txt", false))
require.False(t, rule.Matches("/SECRET/flag.txt", false))
// Where the file system serves both spellings as one file, the rule has to
// cover both or it denies nothing.
require.True(t, rule.Matches("/SECRET/flag.txt", true))
// A pattern written with upper case still relies on the path as written, so
// folding never takes a match away.
upper := &Rule{Regex: regexp.MustCompile("^/Secret/")}
require.True(t, upper.Matches("/Secret/flag.txt", true))
require.False(t, upper.Matches("/public/flag.txt", true))
}
+37 -10
View File
@@ -4,9 +4,39 @@ import (
"errors"
"net/http"
"net/url"
"path"
"strings"
)
// cleanPath resolves dot segments so that the permission checks see the same
// path that the backing file system will ultimately open. The file systems in
// golang.org/x/net/webdav apply path.Clean before joining the backing
// directory, so without this the two layers disagree on which file a request
// names and a rule can be bypassed with e.g. "/public/../secret/file.txt".
func cleanPath(p string) string {
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
cleaned := path.Clean(p)
// path.Clean drops the trailing slash, but rules are prefix matches and are
// commonly written with one, such as "/c/". Dropping it would stop a request
// for the collection itself from matching the rule that names it.
if cleaned != "/" && isCollectionPath(p) {
cleaned += "/"
}
return cleaned
}
// isCollectionPath reports whether p names a collection rather than a resource
// within it. Besides an explicit trailing slash, a trailing "." or ".." segment
// also resolves to the collection itself.
func isCollectionPath(p string) bool {
return strings.HasSuffix(p, "/") || strings.HasSuffix(p, "/.") || strings.HasSuffix(p, "/..")
}
type request struct {
method string
path string
@@ -24,6 +54,11 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
return nil, errors.New("invalid destination header")
}
// RFC 4918, section 10.3, has Destination as an absolute URI, which is
// what clients send in practice. Only the path is relevant here, and
// taking it unconditionally keeps the host out of the matched value.
destination = u.Path
if prefix != "" {
destination = strings.TrimPrefix(u.Path, prefix)
if len(destination) >= len(u.Path) {
@@ -31,11 +66,7 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
}
}
if !strings.HasPrefix(destination, "/") {
destination = "/" + destination
}
ctx.destination = destination
ctx.destination = cleanPath(destination)
}
path := r.URL.Path
@@ -47,11 +78,7 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
}
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
ctx.path = path
ctx.path = cleanPath(path)
return ctx, nil
}