mirror of
https://github.com/hacdias/webdav.git
synced 2026-09-22 03:20:41 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb02929c3a | ||
|
|
802bfa1eb3 | ||
|
|
1c0110bdb5 | ||
|
|
be20c9cc71 | ||
|
|
64c669d8a7 | ||
|
|
6775297f4f | ||
|
|
04c863aefc | ||
|
|
3ded167a52 | ||
|
|
081d20405f | ||
|
|
4733aa03c4 | ||
|
|
6dc4d8de20 | ||
|
|
d0ad4623a9 | ||
|
|
203d2f72bf | ||
|
|
f869dd6276 | ||
|
|
c04649bf40 | ||
|
|
390fe21ed9 | ||
|
|
44e5e02dd3 | ||
|
|
d59dd02f96 | ||
|
|
10183d09bc | ||
|
|
1dceeb296a | ||
|
|
2bf7130f56 | ||
|
|
7ea4cec229 | ||
|
|
de2ac9d327 |
@@ -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.
|
||||||
@@ -13,9 +13,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.27.x"
|
||||||
- run: go build .
|
- run: go build .
|
||||||
env:
|
env:
|
||||||
CGO_ENABLED: '0'
|
CGO_ENABLED: '0'
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.27.x"
|
||||||
- uses: golangci/golangci-lint-action@v9
|
- uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: "latest"
|
version: "latest"
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ jobs:
|
|||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.27.x"
|
||||||
- uses: goreleaser/goreleaser-action@v7
|
- uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
|
|||||||
@@ -10,12 +10,21 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: test
|
name: test (${{ matrix.os }})
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os:
|
||||||
|
- ubuntu-latest
|
||||||
|
- windows-latest
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "1.27.x"
|
||||||
- name: Run test with coverage
|
- name: Run test with race detector and coverage
|
||||||
|
if: runner.os != 'Windows'
|
||||||
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
|
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 ./...
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
# webdav
|
# webdav
|
||||||
|
|
||||||
[](https://goreportcard.com/report/hacdias/webdav)
|
|
||||||
[](https://github.com/hacdias/webdav/releases/latest)
|
[](https://github.com/hacdias/webdav/releases/latest)
|
||||||
[](https://hub.docker.com/r/hacdias/webdav)
|
[](https://hub.docker.com/r/hacdias/webdav)
|
||||||
|
|
||||||
@@ -107,9 +106,24 @@ behindProxy: false
|
|||||||
# that is /data.
|
# that is /data.
|
||||||
directory: /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
|
# 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: 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".
|
# 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
|
permissions: R
|
||||||
|
|
||||||
# The default permissions rules for users. Default is none. Rules are applied
|
# 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'.
|
# Whether or not CORS configuration should be applied. Default is 'false'.
|
||||||
enabled: true
|
enabled: true
|
||||||
credentials: 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.
|
# The following are the default CORS settings when it is enabled.
|
||||||
allowed_hosts:
|
allowed_hosts:
|
||||||
- '*'
|
- '*'
|
||||||
@@ -221,9 +237,23 @@ users:
|
|||||||
# noPassword: true
|
# 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
|
### 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.
|
1. Use `withCredentials = true` in javascript.
|
||||||
2. Use the `username:password@host` syntax.
|
2. Use the `username:password@host` syntax.
|
||||||
@@ -245,11 +275,13 @@ location / {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_redirect off;
|
proxy_redirect off;
|
||||||
|
|
||||||
# Ensure COPY and MOVE commands work. Change https://example.com to the
|
# Ensure COPY and MOVE commands work by rewriting the Destination header to
|
||||||
# correct address where the WebDAV server will be deployed at.
|
# 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;
|
set $dest $http_destination;
|
||||||
if ($http_destination ~ "^https://example.com(?<path>(.+))") {
|
if ($http_destination ~ "^https?://[^/]+(?<path>/.*)$") {
|
||||||
set $dest /$path;
|
set $dest $path;
|
||||||
}
|
}
|
||||||
proxy_set_header Destination $dest;
|
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
|
## Examples
|
||||||
|
|
||||||
### Systemd
|
### Systemd
|
||||||
|
|||||||
+20
@@ -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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
module github.com/hacdias/webdav/v5
|
module github.com/hacdias/webdav/v5
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0
|
github.com/coreos/go-systemd/v22 v22.7.0
|
||||||
@@ -9,28 +9,25 @@ require (
|
|||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/spf13/viper v1.21.0
|
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.12.0
|
github.com/studio-b12/gowebdav v0.13.0
|
||||||
go.uber.org/zap v1.28.0
|
go.uber.org/zap v1.28.0
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.57.0
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260708182226-cdce021fa6c7
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1
|
||||||
golang.org/x/net v0.56.0
|
golang.org/x/net v0.59.0
|
||||||
|
golang.org/x/text v0.42.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // 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/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
github.com/spf13/afero v1.15.0 // indirect
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
github.com/spf13/cast v1.10.0 // indirect
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.48.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
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/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 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
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/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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
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/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 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
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.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
|
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
|
||||||
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
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/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 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
|
||||||
go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
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=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260708182226-cdce021fa6c7 h1:XWbcG8uLNW2EmI8DBzFXp7XwIxmrYK6s7ME2iyyq7Cg=
|
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260708182226-cdce021fa6c7/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs=
|
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1 h1:n8oH0y5uJlfek8vblc51aexgj0qKjh95bYIANGCaxoI=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
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 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=
|
|
||||||
|
|||||||
@@ -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
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
@@ -23,6 +24,8 @@ const (
|
|||||||
DefaultPrefix = "/"
|
DefaultPrefix = "/"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errDirectoryConflict = errors.New("directory and directories cannot both be defined")
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
UserPermissions `mapstructure:",squash"`
|
UserPermissions `mapstructure:",squash"`
|
||||||
Debug bool
|
Debug bool
|
||||||
@@ -100,6 +103,7 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
err = v.Unmarshal(cfg, viper.DecodeHook(mapstructure.ComposeDecodeHookFunc(
|
||||||
|
directoryMountsDecodeHook(),
|
||||||
mapstructure.StringToTimeDurationHookFunc(),
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
mapstructure.StringToSliceHookFunc(","),
|
mapstructure.StringToSliceHookFunc(","),
|
||||||
mapstructure.TextUnmarshallerHookFunc(),
|
mapstructure.TextUnmarshallerHookFunc(),
|
||||||
@@ -108,12 +112,28 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
return nil, err
|
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
|
// Cascade user settings
|
||||||
for i := range cfg.Users {
|
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
|
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)) {
|
if !v.IsSet(fmt.Sprintf("Users.%d.Permissions", i)) {
|
||||||
cfg.Users[i].Permissions = cfg.Permissions
|
cfg.Users[i].Permissions = cfg.Permissions
|
||||||
}
|
}
|
||||||
@@ -145,6 +165,46 @@ func ParseConfig(filename string, flags *pflag.FlagSet) (*Config, error) {
|
|||||||
return cfg, nil
|
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 {
|
func (c *Config) Validate() error {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
@@ -188,6 +248,119 @@ func (c *Config) Validate() error {
|
|||||||
return nil
|
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) {
|
func (cfg *Config) GetLogger() (*zap.Logger, error) {
|
||||||
loggerConfig := zap.NewProductionConfig()
|
loggerConfig := zap.NewProductionConfig()
|
||||||
loggerConfig.DisableCaller = true
|
loggerConfig.DisableCaller = true
|
||||||
@@ -210,10 +383,11 @@ type Log struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CORS struct {
|
type CORS struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Credentials bool
|
Credentials bool
|
||||||
AllowedHeaders []string `mapstructure:"allowed_headers"`
|
AllowPrivateNetwork bool `mapstructure:"allow_private_network"`
|
||||||
AllowedHosts []string `mapstructure:"allowed_hosts"`
|
AllowedHeaders []string `mapstructure:"allowed_headers"`
|
||||||
AllowedMethods []string `mapstructure:"allowed_methods"`
|
AllowedHosts []string `mapstructure:"allowed_hosts"`
|
||||||
ExposedHeaders []string `mapstructure:"exposed_headers"`
|
AllowedMethods []string `mapstructure:"allowed_methods"`
|
||||||
|
ExposedHeaders []string `mapstructure:"exposed_headers"`
|
||||||
}
|
}
|
||||||
|
|||||||
+194
-4
@@ -3,6 +3,7 @@ package lib
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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{"*"}, 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{"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.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) {
|
func TestConfigCascade(t *testing.T) {
|
||||||
t.Parallel()
|
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) {
|
check := func(t *testing.T, cfg *Config) {
|
||||||
require.True(t, cfg.Permissions.Read)
|
require.True(t, cfg.Permissions.Read)
|
||||||
require.True(t, cfg.Permissions.Create)
|
require.True(t, cfg.Permissions.Create)
|
||||||
require.False(t, cfg.Permissions.Delete)
|
require.False(t, cfg.Permissions.Delete)
|
||||||
require.False(t, cfg.Permissions.Update)
|
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.Rules, 1)
|
||||||
|
|
||||||
require.Len(t, cfg.Users, 2)
|
require.Len(t, cfg.Users, 2)
|
||||||
@@ -72,14 +81,14 @@ func TestConfigCascade(t *testing.T) {
|
|||||||
require.True(t, cfg.Users[0].Permissions.Create)
|
require.True(t, cfg.Users[0].Permissions.Create)
|
||||||
require.False(t, cfg.Users[0].Permissions.Delete)
|
require.False(t, cfg.Users[0].Permissions.Delete)
|
||||||
require.False(t, cfg.Users[0].Permissions.Update)
|
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.Len(t, cfg.Users[0].Rules, 1)
|
||||||
|
|
||||||
require.True(t, cfg.Users[1].Permissions.Read)
|
require.True(t, cfg.Users[1].Permissions.Read)
|
||||||
require.False(t, cfg.Users[1].Permissions.Create)
|
require.False(t, cfg.Users[1].Permissions.Create)
|
||||||
require.False(t, cfg.Users[1].Permissions.Delete)
|
require.False(t, cfg.Users[1].Permissions.Delete)
|
||||||
require.False(t, cfg.Users[1].Permissions.Update)
|
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)
|
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) {
|
func TestConfigKeys(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -172,6 +357,7 @@ func TestConfigKeys(t *testing.T) {
|
|||||||
cors:
|
cors:
|
||||||
enabled: true
|
enabled: true
|
||||||
credentials: true
|
credentials: true
|
||||||
|
allow_private_network: true
|
||||||
allowed_headers:
|
allowed_headers:
|
||||||
- Depth
|
- Depth
|
||||||
allowed_hosts:
|
allowed_hosts:
|
||||||
@@ -185,6 +371,7 @@ cors:
|
|||||||
|
|
||||||
require.True(t, cfg.CORS.Enabled)
|
require.True(t, cfg.CORS.Enabled)
|
||||||
require.True(t, cfg.CORS.Credentials)
|
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{"Content-Length", "Content-Range"}, cfg.CORS.ExposedHeaders)
|
||||||
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
|
require.EqualValues(t, []string{"Depth"}, cfg.CORS.AllowedHeaders)
|
||||||
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
|
require.EqualValues(t, []string{"http://localhost:8080"}, cfg.CORS.AllowedHosts)
|
||||||
@@ -308,8 +495,11 @@ func TestConfigEnv(t *testing.T) {
|
|||||||
cfg, err := ParseConfig("", nil)
|
cfg, err := ParseConfig("", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedDirectory, err := filepath.Abs("/test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, 1234, cfg.Port)
|
assert.Equal(t, 1234, cfg.Port)
|
||||||
assert.Equal(t, "/test", cfg.Directory)
|
assert.Equal(t, expectedDirectory, cfg.Directory)
|
||||||
assert.Equal(t, true, cfg.Debug)
|
assert.Equal(t, true, cfg.Debug)
|
||||||
require.True(t, cfg.Permissions.Read)
|
require.True(t, cfg.Permissions.Read)
|
||||||
require.True(t, cfg.Permissions.Create)
|
require.True(t, cfg.Permissions.Create)
|
||||||
|
|||||||
+132
-49
@@ -3,7 +3,6 @@ package lib
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -12,7 +11,8 @@ import (
|
|||||||
|
|
||||||
type handlerUser struct {
|
type handlerUser struct {
|
||||||
User
|
User
|
||||||
webdav.Handler
|
handler webdav.Handler
|
||||||
|
fs permissionsFS
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -33,52 +33,23 @@ func NewHandler(c *Config) (http.Handler, error) {
|
|||||||
h := &Handler{
|
h := &Handler{
|
||||||
noPassword: c.NoPassword,
|
noPassword: c.NoPassword,
|
||||||
behindProxy: c.BehindProxy,
|
behindProxy: c.BehindProxy,
|
||||||
user: &handlerUser{
|
user: newHandlerUser(User{UserPermissions: c.UserPermissions}, c, ls, logFunc),
|
||||||
User: User{
|
users: map[string]*handlerUser{},
|
||||||
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{},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, u := range c.Users {
|
for _, u := range c.Users {
|
||||||
h.users[u.Username] = &handlerUser{
|
h.users[u.Username] = newHandlerUser(u, c, ls, logFunc)
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.CORS.Enabled {
|
if c.CORS.Enabled {
|
||||||
return cors.New(cors.Options{
|
return cors.New(cors.Options{
|
||||||
AllowCredentials: c.CORS.Credentials,
|
AllowCredentials: c.CORS.Credentials,
|
||||||
AllowedOrigins: c.CORS.AllowedHosts,
|
AllowPrivateNetwork: c.CORS.AllowPrivateNetwork,
|
||||||
AllowedMethods: c.CORS.AllowedMethods,
|
AllowedOrigins: c.CORS.AllowedHosts,
|
||||||
AllowedHeaders: c.CORS.AllowedHeaders,
|
AllowedMethods: c.CORS.AllowedMethods,
|
||||||
ExposedHeaders: c.CORS.ExposedHeaders,
|
AllowedHeaders: c.CORS.AllowedHeaders,
|
||||||
OptionsPassthrough: false,
|
ExposedHeaders: c.CORS.ExposedHeaders,
|
||||||
|
OptionsPassthrough: false,
|
||||||
}).Handler(h), nil
|
}).Handler(h), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +64,52 @@ func NewHandler(c *Config) (http.Handler, error) {
|
|||||||
return h, nil
|
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.
|
// 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) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
user := h.user
|
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
|
// 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 {
|
if err != nil {
|
||||||
lZap.Info("invalid request path or destination", zap.Error(err))
|
lZap.Info("invalid request path or destination", zap.Error(err))
|
||||||
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
|
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checks for user permissions relatively to this PATH.
|
fileExists := func(filename string) bool {
|
||||||
allowed := user.Allowed(req, func(filename string) bool {
|
_, err := user.fs.Stat(r.Context(), filename)
|
||||||
_, err := user.FileSystem.Stat(r.Context(), filename)
|
|
||||||
return !os.IsNotExist(err)
|
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))
|
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
|
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" {
|
if r.Method == "HEAD" {
|
||||||
w = responseWriterNoBody{w}
|
w = responseWriterNoBody{w}
|
||||||
}
|
}
|
||||||
@@ -165,8 +248,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
// collection resources.
|
// collection resources.
|
||||||
//
|
//
|
||||||
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
|
// 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) {
|
if r.Method == "GET" || r.Method == "HEAD" {
|
||||||
info, err := user.FileSystem.Stat(r.Context(), strings.TrimPrefix(r.URL.Path, user.Prefix))
|
info, err := user.fs.Stat(r.Context(), req.path)
|
||||||
if err == nil && info.IsDir() {
|
if err == nil && info.IsDir() {
|
||||||
r.Method = "PROPFIND"
|
r.Method = "PROPFIND"
|
||||||
|
|
||||||
@@ -187,7 +270,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Runs the WebDAV.
|
// Runs the WebDAV.
|
||||||
user.ServeHTTP(w, r)
|
user.handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRequestLogger creates a zap.Logger using the request remote ip.
|
// getRequestLogger creates a zap.Logger using the request remote ip.
|
||||||
|
|||||||
+896
-22
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -85,6 +86,29 @@ func TestServerDefaults(t *testing.T) {
|
|||||||
require.ErrorContains(t, client.Write("/foo.txt", []byte("hello world 2"), 0666), "403")
|
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) {
|
func TestServerPartialUpdateOptions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -486,13 +510,21 @@ func TestServerPartialUpdateHonorsLocks(t *testing.T) {
|
|||||||
func TestServerListingCharacters(t *testing.T) {
|
func TestServerListingCharacters(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dir := makeTestDirectory(t, map[string][]byte{
|
contents := map[string][]byte{
|
||||||
"富/foo.txt": []byte("foo"),
|
"富/foo.txt": []byte("foo"),
|
||||||
"你好.txt": []byte("bar"),
|
"你好.txt": []byte("bar"),
|
||||||
"z*.txt": []byte("zbar"),
|
|
||||||
"foo.txt": []byte("foo"),
|
"foo.txt": []byte("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)
|
srv := makeTestServer(t, "directory: "+dir)
|
||||||
client := gowebdav.NewClient(srv.URL, "", "")
|
client := gowebdav.NewClient(srv.URL, "", "")
|
||||||
@@ -500,28 +532,21 @@ func TestServerListingCharacters(t *testing.T) {
|
|||||||
// By default, reading permissions.
|
// By default, reading permissions.
|
||||||
files, err := client.ReadDir("/")
|
files, err := client.ReadDir("/")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, files, 5)
|
require.Len(t, files, len(expectedNames))
|
||||||
|
|
||||||
names := []string{
|
names := make([]string, len(files))
|
||||||
files[0].Name(),
|
for i, file := range files {
|
||||||
files[1].Name(),
|
names[i] = file.Name()
|
||||||
files[2].Name(),
|
|
||||||
files[3].Name(),
|
|
||||||
files[4].Name(),
|
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
|
|
||||||
require.Equal(t, []string{
|
require.Equal(t, expectedNames, names)
|
||||||
"foo.txt",
|
|
||||||
"z*.txt",
|
|
||||||
"你好.txt",
|
|
||||||
"富",
|
|
||||||
"🌹.txt",
|
|
||||||
}, names)
|
|
||||||
|
|
||||||
data, err := client.Read("/z*.txt")
|
if runtime.GOOS != "windows" {
|
||||||
require.NoError(t, err)
|
data, err := client.Read("/z*.txt")
|
||||||
require.EqualValues(t, []byte("zbar"), data)
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, []byte("zbar"), data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestServerAuthentication(t *testing.T) {
|
func TestServerAuthentication(t *testing.T) {
|
||||||
@@ -652,9 +677,13 @@ users:
|
|||||||
|
|
||||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||||
|
|
||||||
|
// A rule denying a path also hides it from the collection containing it.
|
||||||
files, err := client.ReadDir("/")
|
files, err := client.ReadDir("/")
|
||||||
require.NoError(t, err)
|
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)
|
err = client.Write("/foo.txt", []byte("new"), 0666)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -779,9 +808,13 @@ users:
|
|||||||
|
|
||||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
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")
|
files, err := client.ReadDir("/prefix")
|
||||||
require.NoError(t, err)
|
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)
|
err = client.Write("/prefix/foo.txt", []byte("new"), 0666)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -820,6 +853,129 @@ users:
|
|||||||
require.ErrorContains(t, err, "403")
|
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) {
|
func TestServerPermissions(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -887,3 +1043,721 @@ users:
|
|||||||
require.ErrorContains(t, err, "403")
|
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
@@ -1,6 +1,7 @@
|
|||||||
package lib
|
package lib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,27 +10,74 @@ import (
|
|||||||
|
|
||||||
var _ webdav.LockSystem = &lockSystem{}
|
var _ webdav.LockSystem = &lockSystem{}
|
||||||
|
|
||||||
// LockSystem wraps a [webdav.LockSystem] with a root directory, allowing
|
// lockSystem wraps a [webdav.LockSystem], mapping virtual request names to the
|
||||||
// to reuse the same [webdav.LockSystem] for multiple users with different base
|
// real backing paths via resolve. This allows reusing the same
|
||||||
// directories, meaning we can correctly lock the files across different users.
|
// [webdav.LockSystem] for multiple users with different base directories,
|
||||||
|
// meaning we can correctly lock the files across different users.
|
||||||
type lockSystem struct {
|
type lockSystem struct {
|
||||||
webdav.LockSystem
|
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) {
|
func (l *lockSystem) Confirm(now time.Time, name0, name1 string, conditions ...webdav.Condition) (release func(), err error) {
|
||||||
if name0 != "" {
|
if name0 != "" {
|
||||||
name0 = filepath.Join(l.directory, name0)
|
name0, err = l.resolve(name0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if name1 != "" {
|
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...)
|
return l.LockSystem.Confirm(now, name0, name1, conditions...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lockSystem) Create(now time.Time, details webdav.LockDetails) (token string, err error) {
|
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)
|
return l.LockSystem.Create(now, details)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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) {
|
func (u *handlerUser) handleOptions(w http.ResponseWriter, r *http.Request, reqPath string) {
|
||||||
allow := "OPTIONS, LOCK, PUT, MKCOL, PATCH"
|
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() {
|
if fi.IsDir() {
|
||||||
allow = "OPTIONS, LOCK, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND"
|
allow = "OPTIONS, LOCK, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND"
|
||||||
} else {
|
} else {
|
||||||
@@ -97,7 +97,7 @@ func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request
|
|||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
fi, statErr := u.FileSystem.Stat(ctx, reqPath)
|
fi, statErr := u.fs.Stat(ctx, reqPath)
|
||||||
exists := statErr == nil
|
exists := statErr == nil
|
||||||
if statErr != nil && !os.IsNotExist(statErr) {
|
if statErr != nil && !os.IsNotExist(statErr) {
|
||||||
http.Error(w, statErr.Error(), http.StatusMethodNotAllowed)
|
http.Error(w, statErr.Error(), http.StatusMethodNotAllowed)
|
||||||
@@ -157,7 +157,7 @@ func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request
|
|||||||
if !exists {
|
if !exists {
|
||||||
flag |= os.O_CREATE
|
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 err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
http.Error(w, err.Error(), http.StatusConflict)
|
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")
|
hdr := r.Header.Get("If")
|
||||||
if hdr == "" {
|
if hdr == "" {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
token, err := u.LockSystem.Create(now, webdav.LockDetails{
|
token, err := u.handler.LockSystem.Create(now, webdav.LockDetails{
|
||||||
Root: src,
|
Root: src,
|
||||||
Duration: -1,
|
Duration: -1,
|
||||||
ZeroDepth: true,
|
ZeroDepth: true,
|
||||||
@@ -373,7 +373,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
|
|||||||
return nil, http.StatusInternalServerError, err
|
return nil, http.StatusInternalServerError, err
|
||||||
}
|
}
|
||||||
return func() {
|
return func() {
|
||||||
_ = u.LockSystem.Unlock(now, token)
|
_ = u.handler.LockSystem.Unlock(now, token)
|
||||||
}, 0, nil
|
}, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +393,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
|
|||||||
if parsedURL.Host != r.Host {
|
if parsedURL.Host != r.Host {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lsrc, err = stripPartialPrefix(parsedURL.Path, u.Prefix)
|
lsrc, err = stripPartialPrefix(parsedURL.Path, u.handler.Prefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, http.StatusNotFound, err
|
return nil, http.StatusNotFound, err
|
||||||
}
|
}
|
||||||
@@ -401,7 +401,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
|
|||||||
lsrc = src
|
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) {
|
if errors.Is(err, webdav.ErrConfirmationFailed) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
+161
-25
@@ -26,15 +26,41 @@ func (r *Rule) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Matches checks if [Rule] matches the given path.
|
// Matches checks if [Rule] matches the given path. When caseInsensitive is set
|
||||||
func (r *Rule) Matches(path string) bool {
|
// 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 r.Regex != nil {
|
||||||
|
if caseInsensitive {
|
||||||
|
return r.Regex.MatchString(path) || r.Regex.MatchString(foldPath(path))
|
||||||
|
}
|
||||||
|
|
||||||
return r.Regex.MatchString(path)
|
return r.Regex.MatchString(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if caseInsensitive {
|
||||||
|
return strings.HasPrefix(foldPath(path), foldPath(r.Path))
|
||||||
|
}
|
||||||
|
|
||||||
return strings.HasPrefix(path, 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
|
type RulesBehavior string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -44,46 +70,83 @@ const (
|
|||||||
|
|
||||||
type UserPermissions struct {
|
type UserPermissions struct {
|
||||||
Directory string
|
Directory string
|
||||||
|
Directories DirectoryMounts
|
||||||
Permissions Permissions
|
Permissions Permissions
|
||||||
Rules []*Rule
|
Rules []*Rule
|
||||||
RulesBehavior RulesBehavior
|
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
|
// Allowed checks if the user has permission to access a directory/file
|
||||||
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||||
// For COPY and MOVE requests, we first check the permissions for the destination
|
// 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,
|
// 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.
|
// we fail immediately. If no rule matches, we check the global permissions.
|
||||||
if r.method == "COPY" || r.method == "MOVE" {
|
if r.method == "COPY" || r.method == "MOVE" {
|
||||||
dst := r.destination
|
if !p.allowedAt(r.destination, func(perms Permissions) bool {
|
||||||
ruleMatched := false
|
return perms.AllowedDestination(r, fileExists)
|
||||||
|
}) {
|
||||||
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) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Go through rules beginning from the last one, and check the permissions at
|
return p.allowedAt(r.path, func(perms Permissions) bool {
|
||||||
// the source. The first matched rule returns.
|
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-- {
|
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||||
if p.Rules[i].Matches(r.path) {
|
if p.Rules[i].Matches(path, p.caseInsensitive) {
|
||||||
return p.Rules[i].Permissions.Allowed(r, fileExists)
|
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 {
|
func (p *UserPermissions) Validate() error {
|
||||||
@@ -94,6 +157,14 @@ func (p *UserPermissions) Validate() error {
|
|||||||
return fmt.Errorf("invalid permissions: %w", err)
|
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 {
|
for _, r := range p.Rules {
|
||||||
if err := r.Validate(); err != nil {
|
if err := r.Validate(); err != nil {
|
||||||
return fmt.Errorf("invalid permissions: %w", err)
|
return fmt.Errorf("invalid permissions: %w", err)
|
||||||
@@ -110,6 +181,63 @@ func (p *UserPermissions) Validate() error {
|
|||||||
return nil
|
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 {
|
type Permissions struct {
|
||||||
Create bool
|
Create bool
|
||||||
Read bool
|
Read bool
|
||||||
@@ -164,8 +292,16 @@ func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
|
|||||||
return p.Read && p.Delete
|
return p.Read && p.Delete
|
||||||
case "DELETE":
|
case "DELETE":
|
||||||
return p.Delete
|
return p.Delete
|
||||||
case "LOCK", "UNLOCK":
|
case "LOCK":
|
||||||
return p.Create || p.Read || p.Update || p.Delete
|
// 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:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -4,9 +4,39 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"path"
|
||||||
"strings"
|
"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 {
|
type request struct {
|
||||||
method string
|
method string
|
||||||
path string
|
path string
|
||||||
@@ -24,6 +54,11 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
|
|||||||
return nil, errors.New("invalid destination header")
|
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 != "" {
|
if prefix != "" {
|
||||||
destination = strings.TrimPrefix(u.Path, prefix)
|
destination = strings.TrimPrefix(u.Path, prefix)
|
||||||
if len(destination) >= len(u.Path) {
|
if len(destination) >= len(u.Path) {
|
||||||
@@ -31,11 +66,7 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(destination, "/") {
|
ctx.destination = cleanPath(destination)
|
||||||
destination = "/" + destination
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.destination = destination
|
|
||||||
}
|
}
|
||||||
|
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
@@ -47,11 +78,7 @@ func newRequest(r *http.Request, prefix string) (*request, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !strings.HasPrefix(path, "/") {
|
ctx.path = cleanPath(path)
|
||||||
path = "/" + path
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.path = path
|
|
||||||
|
|
||||||
return ctx, nil
|
return ctx, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user