Compare commits

..
13 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
17 changed files with 1249 additions and 107 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.
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.27.x"
- run: go build .
env:
CGO_ENABLED: '0'
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v7
- 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"
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
fetch-depth: 0
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version: "1.27.x"
- uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
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 ./...
+55 -5
View File
@@ -119,6 +119,11 @@ directory: /data
# 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
@@ -152,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:
- '*'
@@ -238,9 +245,15 @@ A `path` rule is a prefix match. A rule written with a trailing slash also cover
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.
@@ -262,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;
}
@@ -291,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
+8 -11
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/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.54.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e
golang.org/x/net v0.57.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.47.0 // indirect
golang.org/x/text v0.40.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
)
+14 -21
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,8 +37,8 @@ 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/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=
@@ -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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e h1:9TjMDOuGaMMTP5f7GXeHeA0JvFqGmv4DYRIWkzoePGI=
golang.org/x/crypto/x509roots/fallback v0.0.0-20260723152544-d701c51f7e4e/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
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
}
+7 -6
View File
@@ -383,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"`
}
+3
View File
@@ -55,6 +55,7 @@ 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) {
@@ -356,6 +357,7 @@ func TestConfigKeys(t *testing.T) {
cors:
enabled: true
credentials: true
allow_private_network: true
allowed_headers:
- Depth
allowed_hosts:
@@ -369,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)
+118 -36
View File
@@ -11,7 +11,8 @@ import (
type handlerUser struct {
User
webdav.Handler
handler webdav.Handler
fs permissionsFS
}
type Handler struct {
@@ -32,28 +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: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, 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: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, 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
}
@@ -68,26 +64,46 @@ func NewHandler(c *Config) (http.Handler, error) {
return h, nil
}
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions,
// selecting between single-directory and multi-directory backing depending on
// whether directories are configured.
func buildWebdavHandler(p UserPermissions, prefix string, noSniff bool, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
h := webdav.Handler{
Prefix: prefix,
Logger: logFunc,
}
// 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 {
h.FileSystem = multiDir{
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.FileSystem = Dir{
Dir: webdav.Dir(p.Directory),
noSniff: noSniff,
}
h.LockSystem = newLockSystem(ls, p.Directory)
}
@@ -131,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))
@@ -151,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}
}
@@ -167,7 +249,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
//
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
if r.Method == "GET" || r.Method == "HEAD" {
info, err := user.FileSystem.Stat(r.Context(), req.path)
info, err := user.fs.Stat(r.Context(), req.path)
if err == nil && info.IsDir() {
r.Method = "PROPFIND"
@@ -188,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.
+511 -2
View File
@@ -86,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()
@@ -654,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)
@@ -781,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)
@@ -1204,6 +1235,484 @@ rules:
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()
+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
}
+78 -15
View File
@@ -26,23 +26,38 @@ 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) bool {
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, "/")
}
@@ -63,6 +78,7 @@ type UserPermissions struct {
directoryExplicit bool
directoriesExplicit bool
useDirectories bool
caseInsensitive bool
}
type DirectoryMount struct {
@@ -93,26 +109,46 @@ func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool
// 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(path) {
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 that 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 the permissions that
// apply there too. Requiring both means the rule can restrict the collection
// without granting access that would otherwise not exist.
for i := len(p.Rules) - 1; i >= 0; i-- {
if p.Rules[i].matchesCollection(path) {
return check(p.Rules[i].Permissions) && check(p.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 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 {
var err error
@@ -127,6 +163,8 @@ func (p *UserPermissions) Validate() error {
}
}
p.caseInsensitive = p.hasCaseInsensitiveBacking()
for _, r := range p.Rules {
if err := r.Validate(); err != nil {
return fmt.Errorf("invalid permissions: %w", err)
@@ -143,6 +181,23 @@ 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{}{}
@@ -237,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))
}