Compare commits

...
23 Commits
Author SHA1 Message Date
Henrique Dias fb02929c3a fix: authorize the destination collection a copy or move replaces
A COPY or MOVE onto an existing destination replaces it, which destroys
everything the destination collection held. Only the source subtree was
authorized per descendant, and the destination itself was authorized as an
update, so a rule restricting a subtree held against DELETE but not against
an overwrite of the collection above it. Removing a collection is now
authorized the way DELETE on it would be, on the collection and on every
path beneath it. Writing over a file stays an update.
2026-09-20 16:25:19 +02:00
renovate[bot] 802bfa1eb3 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 1f7c531 (#363) 2026-09-20 12:01:10 +02:00
renovate[bot] 1c0110bdb5 chore(deps): update all non-major dependencies (#364) 2026-09-20 11:59:31 +02:00
renovate[bot] be20c9cc71 chore(deps): update golang.org/x/crypto/x509roots/fallback digest to 86efde5 (#360) 2026-09-08 13:59:45 +02:00
Henrique Dias 64c669d8a7 docs: improved reverse proxy docs 2026-09-08 13:53:49 +02:00
renovate[bot] 6775297f4f chore(deps): update module golang.org/x/crypto to v0.56.0 (#361) 2026-09-07 16:09:27 +02:00
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 2780 additions and 183 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 ./...
+73 -6
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,24 @@ 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.
# Being overwritten counts as well: a COPY or MOVE onto an existing file replaces
# it and needs U, while one onto an existing collection removes everything it
# holds and needs D, on the collection and on every path under it.
permissions: R
# The default permissions rules for users. Default is none. Rules are applied
@@ -143,6 +157,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 +237,23 @@ 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.
Overwriting a destination is authorized for what it destroys. A `COPY` or `MOVE` onto an existing destination replaces it: RFC4918 has `MOVE` perform a `DELETE` with `Depth: infinity` on the destination first, and requires an overwritten collection to end up with exactly the membership the source had, so either way whatever was there is gone. Replacing a file needs `U` on it, the same permission `PUT` needs. Replacing a collection removes everything it holds, so it needs `D` on that collection and on every path beneath it: a rule withholding `D` anywhere under a destination refuses the overwrite outright, even where it grants `C` and `U`.
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.
@@ -245,11 +275,13 @@ location / {
proxy_set_header Host $host;
proxy_redirect off;
# Ensure COPY and MOVE commands work. Change https://example.com to the
# correct address where the WebDAV server will be deployed at.
# Ensure COPY and MOVE commands work by rewriting the Destination header to
# contain only the path, e.g. /test.txt. Note that the captured group already
# includes the leading slash: adding another one would produce a Destination
# such as //test.txt, which is parsed as a host name and rejected.
set $dest $http_destination;
if ($http_destination ~ "^https://example.com(?<path>(.+))") {
set $dest /$path;
if ($http_destination ~ "^https?://[^/]+(?<path>/.*)$") {
set $dest $path;
}
proxy_set_header Destination $dest;
}
@@ -274,6 +306,41 @@ example.com {
}
```
#### Serving Under a Subpath
If the server is not served from the root of the domain, do not strip the subpath in the reverse proxy. The server needs to see it: `PROPFIND` responses contain the full path of each resource, and clients reject the ones that fall outside of the URL they requested. Pass the subpath through and set [`prefix`](#configuration) accordingly, so that the server strips it itself and adds it back to the responses:
```yaml
prefix: /webdav
```
With Caddy, that means using `handle` instead of `handle_path`, as the latter strips the matched prefix before proxying:
```Caddyfile
example.com {
@hasDest header_regexp dest ^https?://[^/]+(.*)$
header @hasDest Destination {re.dest.1}
handle /webdav* {
reverse_proxy 127.0.0.1:6065 {
header_up X-Real-IP {remote_host}
header_up REMOTE-HOST {remote_host}
}
}
}
```
With Nginx, use a `location` block for the subpath and keep `proxy_pass` without a trailing path, as a trailing path would replace the prefix:
```nginx
location /webdav {
proxy_pass http://127.0.0.1:6065;
# ... the remaining headers, as above.
}
```
Both the request path and the `Destination` header must carry the prefix. A request without it is answered with `400 Bad Request`.
## Examples
### Systemd
+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.57.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1
golang.org/x/net v0.59.0
golang.org/x/text v0.42.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.48.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.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1 h1:n8oH0y5uJlfek8vblc51aexgj0qKjh95bYIANGCaxoI=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
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)
+132 -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,70 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// MOVE and DELETE act on a whole source subtree in one call, so every
// descendant needs authorizing here. Reading COPY and PROPFIND out of the
// source goes 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
}
}
// Excerpt from RFC4918, section 9.9.3, on MOVE:
//
// If a resource exists at the destination and the Overwrite header is
// "T", then prior to performing the move, the server MUST perform a
// DELETE with "Depth: infinity" on the destination resource.
//
// And from section 9.8.4, on COPY:
//
// When a collection is overwritten, the membership of the destination
// collection after the successful COPY request MUST be the same
// membership as the source collection immediately before the COPY.
//
// Either way whatever the destination collection held is gone, which
// golang.org/x/net/webdav carries out as a RemoveAll before the rename or
// the copy. Writing over a file is an update, already authorized by Allowed,
// but removing a collection takes everything under it. Nothing writes to
// those descendants, they are only destroyed, so authorize the destination
// the way DELETE on that collection would be.
if (r.Method == "MOVE" || r.Method == "COPY") && req.destination != "" {
info, err := user.fs.Stat(r.Context(), req.destination)
if err == nil && info.IsDir() {
deletable := func(p Permissions) bool { return p.Delete }
// allowedThroughout reaches descendants only, and the destination
// check in Allowed covers the collection itself as an update rather
// than a delete, so that is checked here.
ok := user.allowedAt(req.destination, deletable)
if ok {
ok, err = user.fs.allowedThroughout(r.Context(), req.destination, deletable)
if err != nil {
lZap.Error("could not authorize destination subtree", zap.String("destination", req.destination), zap.Error(err))
w.WriteHeader(http.StatusForbidden)
return
}
}
if !ok {
lZap.Info("denied by a rule on the destination collection", zap.String("method", r.Method), zap.String("destination", req.destination))
w.WriteHeader(http.StatusForbidden)
return
}
}
}
if r.Method == "HEAD" {
w = responseWriterNoBody{w}
}
@@ -165,8 +248,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 +270,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.
+896 -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,721 @@ 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"))
}
// TestServerRulesDestinationOverwriteDescendants covers a COPY or MOVE onto an
// existing collection, which replaces it: the server deletes the destination
// with "Depth: infinity" first, reaching descendants that authorizing only the
// destination itself let it destroy.
func TestServerRulesDestinationOverwriteDescendants(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"shared/protected/confidential.txt": []byte("top secret"),
"empty/.keep": []byte(""),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- path: "/shared/protected/"
permissions: R
`, dir))
defer srv.Close()
confidential := filepath.Join(dir, "shared", "protected", "confidential.txt")
// Controls: the rule holds when the request names the denied subtree.
code, _ := doRequest(t, "DELETE", srv.URL+"/shared/protected/confidential.txt", "", "", nil, "")
require.Equal(t, http.StatusForbidden, code)
code, _ = doRequest(t, "DELETE", srv.URL+"/shared/", "", "", nil, "")
require.Equal(t, http.StatusForbidden, code)
// Overwriting the collection above the rule destroys the denied subtree, so
// it is refused for the same reason DELETE is.
code, _ = doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
"Destination": srv.URL + "/shared/",
"Overwrite": "T",
}, "")
require.Equal(t, http.StatusForbidden, code)
// COPY overwrites unless the header says otherwise, so it needs no Overwrite.
code, _ = doRequest(t, "COPY", srv.URL+"/empty/", "", "", map[string]string{
"Destination": srv.URL + "/shared/",
"Depth": "infinity",
}, "")
require.Equal(t, http.StatusForbidden, code)
require.FileExists(t, confidential)
}
// TestServerRulesDestinationOverwriteDescendantsMultiDir is the same defect
// across mounts, where the destination collection lives under another mount.
func TestServerRulesDestinationOverwriteDescendantsMultiDir(t *testing.T) {
t.Parallel()
dirA := makeTestDirectory(t, map[string][]byte{"shared/protected/flag.txt": []byte("mount secret")})
dirB := makeTestDirectory(t, map[string][]byte{"empty/.keep": []byte("")})
srv := makeTestServer(t, fmt.Sprintf(`
permissions: CRUD
directories:
- name: alpha
path: %s
- name: beta
path: %s
rules:
- path: "/alpha/shared/protected/"
permissions: R
`, dirA, dirB))
defer srv.Close()
code, _ := doRequest(t, "MOVE", srv.URL+"/beta/empty/", "", "", map[string]string{
"Destination": srv.URL + "/alpha/shared/",
"Overwrite": "T",
}, "")
require.Equal(t, http.StatusForbidden, code)
code, _ = doRequest(t, "COPY", srv.URL+"/beta/empty/", "", "", map[string]string{
"Destination": srv.URL + "/alpha/shared/",
"Depth": "infinity",
}, "")
require.Equal(t, http.StatusForbidden, code)
require.FileExists(t, filepath.Join(dirA, "shared", "protected", "flag.txt"))
}
// TestServerRulesDestinationOverwriteRequiresDelete covers the permission class
// the overwrite is authorized under. Removing a collection is delete-class, so a
// rule that grants writes but withholds D refuses the overwrite, whether it
// governs the destination itself or something beneath it.
func TestServerRulesDestinationOverwriteRequiresDelete(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"empty/.keep": []byte(""),
"empty2/.keep": []byte(""),
"nodelete/note.txt": []byte("kept by the rule on the collection"),
"shared/keep/n.txt": []byte("kept by the rule on a descendant"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- path: "/nodelete/"
permissions: CRU
- path: "/shared/keep/"
permissions: CRU
`, dir))
defer srv.Close()
// The rule governs the destination collection itself.
code, _ := doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
"Destination": srv.URL + "/nodelete/",
"Overwrite": "T",
}, "")
require.Equal(t, http.StatusForbidden, code)
require.FileExists(t, filepath.Join(dir, "nodelete", "note.txt"))
// The rule governs a descendant of the destination collection.
code, _ = doRequest(t, "MOVE", srv.URL+"/empty2/", "", "", map[string]string{
"Destination": srv.URL + "/shared/",
"Overwrite": "T",
}, "")
require.Equal(t, http.StatusForbidden, code)
require.FileExists(t, filepath.Join(dir, "shared", "keep", "n.txt"))
}
// TestServerDestinationOverwriteAllowed pins what overwriting a destination is
// still allowed to do, so the delete-class check on collections does not spread
// to writing over a file. Replacing a file is update-class, the same class PUT
// over an existing file needs, which clients rely on when they save by writing a
// temporary file and moving it over the target.
func TestServerDestinationOverwriteAllowed(t *testing.T) {
t.Parallel()
dir := makeTestDirectory(t, map[string][]byte{
"empty/.keep": []byte(""),
"empty2/.keep": []byte(""),
"plain/note.txt": []byte("plain"),
"updatable/doc.txt": []byte("updatable"),
"source.txt": []byte("source"),
})
srv := makeTestServer(t, fmt.Sprintf(`
directory: %s
permissions: CRUD
rules:
- path: "/updatable/doc.txt"
permissions: RU
`, dir))
defer srv.Close()
// No rule withholds D anywhere under the destination.
code, _ := doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
"Destination": srv.URL + "/plain/",
"Overwrite": "T",
}, "")
require.Equal(t, http.StatusNoContent, code)
// A destination that does not exist is created, not overwritten.
code, _ = doRequest(t, "MOVE", srv.URL+"/empty2/", "", "", map[string]string{
"Destination": srv.URL + "/fresh/",
"Overwrite": "T",
}, "")
require.Equal(t, http.StatusCreated, code)
// A file destination needs only the U its rule grants, not D.
code, _ = doRequest(t, "MOVE", srv.URL+"/source.txt", "", "", map[string]string{
"Destination": srv.URL + "/updatable/doc.txt",
"Overwrite": "T",
}, "")
require.Equal(t, http.StatusNoContent, code)
}
// 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
}