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 |
@@ -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.
|
||||
@@ -119,6 +119,11 @@ directory: /data
|
||||
# The default permissions for users. This is a case insensitive option. Possible
|
||||
# permissions: C (Create), R (Read), U (Update), D (Delete). You can combine multiple
|
||||
# permissions. For example, to allow to read and create, set "RC". Default is "R".
|
||||
# LOCK counts as a write: it needs U on a path that exists and C on one that does
|
||||
# not, since locking a path that does not exist creates it.
|
||||
# Being overwritten counts as well: a COPY or MOVE onto an existing file replaces
|
||||
# it and needs U, while one onto an existing collection removes everything it
|
||||
# holds and needs D, on the collection and on every path under it.
|
||||
permissions: R
|
||||
|
||||
# The default permissions rules for users. Default is none. Rules are applied
|
||||
@@ -240,6 +245,12 @@ A `path` rule is a prefix match. A rule written with a trailing slash also cover
|
||||
|
||||
A `regex` rule is matched literally against the path, and gets none of the above handling. In particular `regex: "^/secret/"` does **not** match a request for `/secret` itself. Write `regex: "^/secret(/|$)"` if you want to cover the collection too.
|
||||
|
||||
Rules apply to every path an operation touches, not only the one it names. Collection listings leave out entries the rules deny, copying a collection leaves those entries behind, and a `MOVE` or `DELETE` that would act on a denied descendant is refused outright.
|
||||
|
||||
Overwriting a destination is authorized for what it destroys. A `COPY` or `MOVE` onto an existing destination replaces it: RFC4918 has `MOVE` perform a `DELETE` with `Depth: infinity` on the destination first, and requires an overwritten collection to end up with exactly the membership the source had, so either way whatever was there is gone. Replacing a file needs `U` on it, the same permission `PUT` needs. Replacing a collection removes everything it holds, so it needs `D` on that collection and on every path beneath it: a rule withholding `D` anywhere under a destination refuses the overwrite outright, even where it grants `C` and `U`.
|
||||
|
||||
Rules follow the case sensitivity of the file system, which each served directory is probed for at startup. Where names are case-insensitive, as on APFS and NTFS, `path: /secret/` also covers `/SECRET/`, a `regex` is matched against the folded path as well as the path as written, and Unicode normal forms count as one name. Elsewhere rules are matched exactly, since `/secret` and `/SECRET` are then different directories.
|
||||
|
||||
### CORS
|
||||
|
||||
The `allowed_*` properties are optional, the default value for each of them will be `*`. `exposed_headers` is optional as well, but is not set if not defined. Setting `allow_private_network` to `true` to allow Private-Network-Access preflight requests. Setting `credentials` to `true` will allow you to:
|
||||
@@ -264,11 +275,13 @@ location / {
|
||||
proxy_set_header Host $host;
|
||||
proxy_redirect off;
|
||||
|
||||
# Ensure COPY and MOVE commands work. Change https://example.com to the
|
||||
# correct address where the WebDAV server will be deployed at.
|
||||
# Ensure COPY and MOVE commands work by rewriting the Destination header to
|
||||
# contain only the path, e.g. /test.txt. Note that the captured group already
|
||||
# includes the leading slash: adding another one would produce a Destination
|
||||
# such as //test.txt, which is parsed as a host name and rejected.
|
||||
set $dest $http_destination;
|
||||
if ($http_destination ~ "^https://example.com(?<path>(.+))") {
|
||||
set $dest /$path;
|
||||
if ($http_destination ~ "^https?://[^/]+(?<path>/.*)$") {
|
||||
set $dest $path;
|
||||
}
|
||||
proxy_set_header Destination $dest;
|
||||
}
|
||||
@@ -293,6 +306,41 @@ example.com {
|
||||
}
|
||||
```
|
||||
|
||||
#### Serving Under a Subpath
|
||||
|
||||
If the server is not served from the root of the domain, do not strip the subpath in the reverse proxy. The server needs to see it: `PROPFIND` responses contain the full path of each resource, and clients reject the ones that fall outside of the URL they requested. Pass the subpath through and set [`prefix`](#configuration) accordingly, so that the server strips it itself and adds it back to the responses:
|
||||
|
||||
```yaml
|
||||
prefix: /webdav
|
||||
```
|
||||
|
||||
With Caddy, that means using `handle` instead of `handle_path`, as the latter strips the matched prefix before proxying:
|
||||
|
||||
```Caddyfile
|
||||
example.com {
|
||||
@hasDest header_regexp dest ^https?://[^/]+(.*)$
|
||||
header @hasDest Destination {re.dest.1}
|
||||
|
||||
handle /webdav* {
|
||||
reverse_proxy 127.0.0.1:6065 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up REMOTE-HOST {remote_host}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
With Nginx, use a `location` block for the subpath and keep `proxy_pass` without a trailing path, as a trailing path would replace the prefix:
|
||||
|
||||
```nginx
|
||||
location /webdav {
|
||||
proxy_pass http://127.0.0.1:6065;
|
||||
# ... the remaining headers, as above.
|
||||
}
|
||||
```
|
||||
|
||||
Both the request path and the `Destination` header must carry the prefix. A request without it is answered with `400 Bad Request`.
|
||||
|
||||
## Examples
|
||||
|
||||
### Systemd
|
||||
|
||||
@@ -12,9 +12,10 @@ require (
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/studio-b12/gowebdav v0.13.0
|
||||
go.uber.org/zap v1.28.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb
|
||||
golang.org/x/net v0.58.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/text v0.42.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +29,5 @@ require (
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
)
|
||||
|
||||
@@ -52,14 +52,14 @@ go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb h1:3OQBwC/IAO9+1/yWsvWkE1Lw5InpHGlZxXZHUcWBouA=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260826144058-afebf4cb4efb/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1 h1:n8oH0y5uJlfek8vblc51aexgj0qKjh95bYIANGCaxoI=
|
||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20260920014000-1f7c531b64a1/go.mod h1:HPze8vhfG6fO06AM+VSvxRm4E3+5Yk375mgrJ5M2z1E=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+111
-30
@@ -11,7 +11,8 @@ import (
|
||||
|
||||
type handlerUser struct {
|
||||
User
|
||||
webdav.Handler
|
||||
handler webdav.Handler
|
||||
fs permissionsFS
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
@@ -32,18 +33,12 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
h := &Handler{
|
||||
noPassword: c.NoPassword,
|
||||
behindProxy: c.BehindProxy,
|
||||
user: &handlerUser{
|
||||
User: User{UserPermissions: c.UserPermissions},
|
||||
Handler: buildWebdavHandler(c.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||
},
|
||||
users: map[string]*handlerUser{},
|
||||
user: newHandlerUser(User{UserPermissions: c.UserPermissions}, c, ls, logFunc),
|
||||
users: map[string]*handlerUser{},
|
||||
}
|
||||
|
||||
for _, u := range c.Users {
|
||||
h.users[u.Username] = &handlerUser{
|
||||
User: u,
|
||||
Handler: buildWebdavHandler(u.UserPermissions, c.Prefix, c.NoSniff, ls, logFunc),
|
||||
}
|
||||
h.users[u.Username] = newHandlerUser(u, c, ls, logFunc)
|
||||
}
|
||||
|
||||
if c.CORS.Enabled {
|
||||
@@ -69,26 +64,46 @@ func NewHandler(c *Config) (http.Handler, error) {
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions,
|
||||
// selecting between single-directory and multi-directory backing depending on
|
||||
// whether directories are configured.
|
||||
func buildWebdavHandler(p UserPermissions, prefix string, noSniff bool, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
|
||||
h := webdav.Handler{
|
||||
Prefix: prefix,
|
||||
Logger: logFunc,
|
||||
}
|
||||
// newHandlerUser prepares a user for serving, keeping the unwrapped file system
|
||||
// alongside the handler.
|
||||
func newHandlerUser(u User, c *Config, ls webdav.LockSystem, logFunc func(*http.Request, error)) *handlerUser {
|
||||
fs := permissionsFS{fs: buildFileSystem(u.UserPermissions, c.NoSniff), perms: u.UserPermissions}
|
||||
|
||||
return &handlerUser{
|
||||
User: u,
|
||||
handler: buildWebdavHandler(u.UserPermissions, fs, c.Prefix, ls, logFunc),
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// buildFileSystem creates the unfiltered [webdav.FileSystem] for a set of user
|
||||
// permissions, selecting between single-directory and multi-directory backing
|
||||
// depending on whether directories are configured.
|
||||
func buildFileSystem(p UserPermissions, noSniff bool) webdav.FileSystem {
|
||||
if p.useDirectories {
|
||||
h.FileSystem = multiDir{
|
||||
return multiDir{
|
||||
mounts: p.Directories,
|
||||
noSniff: noSniff,
|
||||
}
|
||||
}
|
||||
|
||||
return Dir{
|
||||
Dir: webdav.Dir(p.Directory),
|
||||
noSniff: noSniff,
|
||||
}
|
||||
}
|
||||
|
||||
// buildWebdavHandler creates the [webdav.Handler] for a set of user permissions.
|
||||
func buildWebdavHandler(p UserPermissions, fs permissionsFS, prefix string, ls webdav.LockSystem, logFunc func(*http.Request, error)) webdav.Handler {
|
||||
h := webdav.Handler{
|
||||
Prefix: prefix,
|
||||
Logger: logFunc,
|
||||
FileSystem: fs,
|
||||
}
|
||||
|
||||
if p.useDirectories {
|
||||
h.LockSystem = newMultiDirLockSystem(ls, p.Directories)
|
||||
} else {
|
||||
h.FileSystem = Dir{
|
||||
Dir: webdav.Dir(p.Directory),
|
||||
noSniff: noSniff,
|
||||
}
|
||||
h.LockSystem = newLockSystem(ls, p.Directory)
|
||||
}
|
||||
|
||||
@@ -132,18 +147,20 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Convert the HTTP request into an internal request type
|
||||
req, err := newRequest(r, h.user.Prefix)
|
||||
req, err := newRequest(r, h.user.handler.Prefix)
|
||||
if err != nil {
|
||||
lZap.Info("invalid request path or destination", zap.Error(err))
|
||||
http.Error(w, "Invalid request path or destination", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Checks for user permissions relatively to this PATH.
|
||||
allowed := user.Allowed(req, func(filename string) bool {
|
||||
_, err := user.FileSystem.Stat(r.Context(), filename)
|
||||
fileExists := func(filename string) bool {
|
||||
_, err := user.fs.Stat(r.Context(), filename)
|
||||
return !os.IsNotExist(err)
|
||||
})
|
||||
}
|
||||
|
||||
// Checks for user permissions relatively to this PATH.
|
||||
allowed := user.Allowed(req, fileExists)
|
||||
|
||||
lZap.Debug("allowed & method & path", zap.Bool("allowed", allowed), zap.String("method", r.Method), zap.String("path", r.URL.Path))
|
||||
|
||||
@@ -152,6 +169,70 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// MOVE and DELETE act on a whole source subtree in one call, so every
|
||||
// descendant needs authorizing here. Reading COPY and PROPFIND out of the
|
||||
// source goes through permFS instead.
|
||||
if r.Method == "MOVE" || r.Method == "DELETE" {
|
||||
ok, err := user.fs.allowedThroughout(r.Context(), req.path, func(p Permissions) bool {
|
||||
return p.Allowed(req, fileExists)
|
||||
})
|
||||
if err != nil {
|
||||
lZap.Error("could not authorize subtree", zap.String("path", req.path), zap.Error(err))
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
if !ok {
|
||||
lZap.Info("denied by a rule on a descendant", zap.String("method", r.Method), zap.String("path", req.path))
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Excerpt from RFC4918, section 9.9.3, on MOVE:
|
||||
//
|
||||
// If a resource exists at the destination and the Overwrite header is
|
||||
// "T", then prior to performing the move, the server MUST perform a
|
||||
// DELETE with "Depth: infinity" on the destination resource.
|
||||
//
|
||||
// And from section 9.8.4, on COPY:
|
||||
//
|
||||
// When a collection is overwritten, the membership of the destination
|
||||
// collection after the successful COPY request MUST be the same
|
||||
// membership as the source collection immediately before the COPY.
|
||||
//
|
||||
// Either way whatever the destination collection held is gone, which
|
||||
// golang.org/x/net/webdav carries out as a RemoveAll before the rename or
|
||||
// the copy. Writing over a file is an update, already authorized by Allowed,
|
||||
// but removing a collection takes everything under it. Nothing writes to
|
||||
// those descendants, they are only destroyed, so authorize the destination
|
||||
// the way DELETE on that collection would be.
|
||||
if (r.Method == "MOVE" || r.Method == "COPY") && req.destination != "" {
|
||||
info, err := user.fs.Stat(r.Context(), req.destination)
|
||||
if err == nil && info.IsDir() {
|
||||
deletable := func(p Permissions) bool { return p.Delete }
|
||||
|
||||
// allowedThroughout reaches descendants only, and the destination
|
||||
// check in Allowed covers the collection itself as an update rather
|
||||
// than a delete, so that is checked here.
|
||||
ok := user.allowedAt(req.destination, deletable)
|
||||
if ok {
|
||||
ok, err = user.fs.allowedThroughout(r.Context(), req.destination, deletable)
|
||||
if err != nil {
|
||||
lZap.Error("could not authorize destination subtree", zap.String("destination", req.destination), zap.Error(err))
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
lZap.Info("denied by a rule on the destination collection", zap.String("method", r.Method), zap.String("destination", req.destination))
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if r.Method == "HEAD" {
|
||||
w = responseWriterNoBody{w}
|
||||
}
|
||||
@@ -168,7 +249,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// GET (or HEAD), when applied to collection, will return the same as PROPFIND method.
|
||||
if r.Method == "GET" || r.Method == "HEAD" {
|
||||
info, err := user.FileSystem.Stat(r.Context(), req.path)
|
||||
info, err := user.fs.Stat(r.Context(), req.path)
|
||||
if err == nil && info.IsDir() {
|
||||
r.Method = "PROPFIND"
|
||||
|
||||
@@ -189,7 +270,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Runs the WebDAV.
|
||||
user.ServeHTTP(w, r)
|
||||
user.handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// getRequestLogger creates a zap.Logger using the request remote ip.
|
||||
|
||||
+488
-2
@@ -677,9 +677,13 @@ users:
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||
|
||||
// A rule denying a path also hides it from the collection containing it.
|
||||
files, err := client.ReadDir("/")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 5)
|
||||
require.Len(t, files, 4)
|
||||
for _, f := range files {
|
||||
require.NotEqual(t, "c", f.Name())
|
||||
}
|
||||
|
||||
err = client.Write("/foo.txt", []byte("new"), 0666)
|
||||
require.NoError(t, err)
|
||||
@@ -804,9 +808,13 @@ users:
|
||||
|
||||
client := gowebdav.NewClient(srv.URL, "basic", "basic")
|
||||
|
||||
// A rule denying a path also hides it from the collection containing it.
|
||||
files, err := client.ReadDir("/prefix")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 5)
|
||||
require.Len(t, files, 4)
|
||||
for _, f := range files {
|
||||
require.NotEqual(t, "c", f.Name())
|
||||
}
|
||||
|
||||
err = client.Write("/prefix/foo.txt", []byte("new"), 0666)
|
||||
require.NoError(t, err)
|
||||
@@ -1227,6 +1235,484 @@ rules:
|
||||
require.Equal(t, http.StatusForbidden, do(t, "PROPFIND", grantSrv.URL+"/pub"))
|
||||
}
|
||||
|
||||
// doRequest sends a raw request, for what gowebdav does not expose directly.
|
||||
// An empty username sends no credentials.
|
||||
func doRequest(t *testing.T, method, url, username, password string, headers map[string]string, body string) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
if username != "" {
|
||||
req.SetBasicAuth(username, password)
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, resp.Body.Close())
|
||||
|
||||
return resp.StatusCode, string(data)
|
||||
}
|
||||
|
||||
const exclusiveWriteLock = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<D:lockinfo xmlns:D="DAV:">
|
||||
<D:lockscope><D:exclusive/></D:lockscope>
|
||||
<D:locktype><D:write/></D:locktype>
|
||||
<D:owner>tester</D:owner>
|
||||
</D:lockinfo>`
|
||||
|
||||
// TestServerRulesShadowedCollection covers a broader rule shadowing one that
|
||||
// names a collection: resolving "/data/secret" through "/data/" would skip the
|
||||
// deny rule, leaving the collection listable, relocatable and removable.
|
||||
func TestServerRulesShadowedCollection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
config string
|
||||
username string
|
||||
}{
|
||||
{
|
||||
name: "path rule shadows",
|
||||
config: `
|
||||
permissions: none
|
||||
rules:
|
||||
- path: "/data/"
|
||||
permissions: CRUD
|
||||
- path: "/data/secret/"
|
||||
permissions: none`,
|
||||
},
|
||||
{
|
||||
name: "regex rule shadows",
|
||||
config: `
|
||||
permissions: none
|
||||
rules:
|
||||
- regex: "^/data/"
|
||||
permissions: CRUD
|
||||
- path: "/data/secret/"
|
||||
permissions: none`,
|
||||
},
|
||||
{
|
||||
// The shadowing rule need not sit next to the rule it shadows.
|
||||
name: "appended global rule shadows",
|
||||
username: "basic",
|
||||
config: `
|
||||
permissions: none
|
||||
rules:
|
||||
- path: "/data/"
|
||||
permissions: CRUD
|
||||
users:
|
||||
- username: basic
|
||||
password: basic
|
||||
rulesBehavior: append
|
||||
rules:
|
||||
- path: "/data/secret/"
|
||||
permissions: none`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"data/secret/flag.txt": []byte("secret"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf("directory: %s\n%s\n", dir, tc.config))
|
||||
defer srv.Close()
|
||||
|
||||
do := func(method, path string, headers map[string]string) int {
|
||||
t.Helper()
|
||||
code, _ := doRequest(t, method, srv.URL+path, tc.username, "basic", headers, "")
|
||||
return code
|
||||
}
|
||||
|
||||
// Controls: the deny rule holds inside the collection, and for the
|
||||
// collection named with its trailing slash.
|
||||
require.Equal(t, http.StatusForbidden, do("GET", "/data/secret/flag.txt", nil))
|
||||
require.Equal(t, http.StatusForbidden, do("PROPFIND", "/data/secret/", map[string]string{"Depth": "1"}))
|
||||
|
||||
// Named without the trailing slash it is the same collection.
|
||||
require.Equal(t, http.StatusForbidden, do("PROPFIND", "/data/secret", map[string]string{"Depth": "1"}))
|
||||
require.Equal(t, http.StatusForbidden, do("DELETE", "/data/secret", nil))
|
||||
require.Equal(t, http.StatusForbidden, do("MOVE", "/data/secret", map[string]string{"Destination": srv.URL + "/data/exposed"}))
|
||||
require.Equal(t, http.StatusForbidden, do("COPY", "/data/secret", map[string]string{"Destination": srv.URL + "/data/copied"}))
|
||||
|
||||
require.FileExists(t, filepath.Join(dir, "data", "secret", "flag.txt"))
|
||||
require.NoFileExists(t, filepath.Join(dir, "data", "exposed", "flag.txt"))
|
||||
require.NoFileExists(t, filepath.Join(dir, "data", "copied", "flag.txt"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerRulesCollectionGrantedByEnclosingRule guards the other direction: a
|
||||
// rule naming a collection must not lose access an enclosing rule grants.
|
||||
func TestServerRulesCollectionGrantedByEnclosingRule(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{"data/sub/foo.txt": []byte("foo")})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: none
|
||||
rules:
|
||||
- path: "/data/"
|
||||
permissions: CRUD
|
||||
- path: "/data/sub/"
|
||||
permissions: CRUD
|
||||
`, dir))
|
||||
defer srv.Close()
|
||||
|
||||
code, _ := doRequest(t, "PROPFIND", srv.URL+"/data/sub", "", "", map[string]string{"Depth": "1"}, "")
|
||||
require.Equal(t, http.StatusMultiStatus, code)
|
||||
}
|
||||
|
||||
// TestServerRulesRecursiveDescendants covers operations acting on a whole
|
||||
// subtree, which authorizing only the requested path let reach denied
|
||||
// descendants: reading, enumerating, relocating and destroying them.
|
||||
func TestServerRulesRecursiveDescendants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"secret/flag.txt": []byte("top secret"),
|
||||
"secret/public-note.txt": []byte("note"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
rules:
|
||||
- path: "/secret/flag.txt"
|
||||
permissions: none
|
||||
`, dir))
|
||||
defer srv.Close()
|
||||
|
||||
// Control: the rule holds when the request names the denied path.
|
||||
code, _ := doRequest(t, "GET", srv.URL+"/secret/flag.txt", "", "", nil, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
// Enumerating the parent must not report the denied descendant.
|
||||
code, body := doRequest(t, "PROPFIND", srv.URL+"/secret", "", "", map[string]string{"Depth": "infinity"}, "")
|
||||
require.Equal(t, http.StatusMultiStatus, code)
|
||||
require.NotContains(t, body, "flag.txt")
|
||||
require.Contains(t, body, "public-note.txt")
|
||||
|
||||
// Copying the parent must leave the denied descendant behind, not carry it
|
||||
// to a path no rule covers. The permitted sibling still copies.
|
||||
code, _ = doRequest(t, "COPY", srv.URL+"/secret", "", "", map[string]string{"Destination": srv.URL + "/stolen", "Depth": "infinity"}, "")
|
||||
require.Equal(t, http.StatusCreated, code)
|
||||
|
||||
code, _ = doRequest(t, "GET", srv.URL+"/stolen/flag.txt", "", "", nil, "")
|
||||
require.Equal(t, http.StatusNotFound, code)
|
||||
|
||||
code, _ = doRequest(t, "GET", srv.URL+"/stolen/public-note.txt", "", "", nil, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
|
||||
// MOVE and DELETE act on the subtree in one call, so they cannot be partial.
|
||||
code, _ = doRequest(t, "MOVE", srv.URL+"/secret", "", "", map[string]string{"Destination": srv.URL + "/moved"}, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
code, _ = doRequest(t, "DELETE", srv.URL+"/secret", "", "", nil, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
require.FileExists(t, filepath.Join(dir, "secret", "flag.txt"))
|
||||
}
|
||||
|
||||
// TestServerRulesRecursiveDescendantsMultiDir is the same defect across mounts,
|
||||
// where copying out of one lands the denied file in another.
|
||||
func TestServerRulesRecursiveDescendantsMultiDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirA := makeTestDirectory(t, map[string][]byte{"secret/flag.txt": []byte("mount secret")})
|
||||
dirB := makeTestDirectory(t, map[string][]byte{"keep.txt": []byte("keep")})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
permissions: CRUD
|
||||
directories:
|
||||
- name: alpha
|
||||
path: %s
|
||||
- name: beta
|
||||
path: %s
|
||||
rules:
|
||||
- path: "/alpha/secret/flag.txt"
|
||||
permissions: none
|
||||
`, dirA, dirB))
|
||||
defer srv.Close()
|
||||
|
||||
code, _ := doRequest(t, "COPY", srv.URL+"/alpha/secret", "", "", map[string]string{"Destination": srv.URL + "/beta/stolen", "Depth": "infinity"}, "")
|
||||
require.Equal(t, http.StatusCreated, code)
|
||||
|
||||
code, _ = doRequest(t, "GET", srv.URL+"/beta/stolen/flag.txt", "", "", nil, "")
|
||||
require.Equal(t, http.StatusNotFound, code)
|
||||
|
||||
code, _ = doRequest(t, "DELETE", srv.URL+"/alpha/secret", "", "", nil, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
require.FileExists(t, filepath.Join(dirA, "secret", "flag.txt"))
|
||||
}
|
||||
|
||||
// TestServerRulesDestinationOverwriteDescendants covers a COPY or MOVE onto an
|
||||
// existing collection, which replaces it: the server deletes the destination
|
||||
// with "Depth: infinity" first, reaching descendants that authorizing only the
|
||||
// destination itself let it destroy.
|
||||
func TestServerRulesDestinationOverwriteDescendants(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"shared/protected/confidential.txt": []byte("top secret"),
|
||||
"empty/.keep": []byte(""),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
rules:
|
||||
- path: "/shared/protected/"
|
||||
permissions: R
|
||||
`, dir))
|
||||
defer srv.Close()
|
||||
|
||||
confidential := filepath.Join(dir, "shared", "protected", "confidential.txt")
|
||||
|
||||
// Controls: the rule holds when the request names the denied subtree.
|
||||
code, _ := doRequest(t, "DELETE", srv.URL+"/shared/protected/confidential.txt", "", "", nil, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
code, _ = doRequest(t, "DELETE", srv.URL+"/shared/", "", "", nil, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
// Overwriting the collection above the rule destroys the denied subtree, so
|
||||
// it is refused for the same reason DELETE is.
|
||||
code, _ = doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/shared/",
|
||||
"Overwrite": "T",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
// COPY overwrites unless the header says otherwise, so it needs no Overwrite.
|
||||
code, _ = doRequest(t, "COPY", srv.URL+"/empty/", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/shared/",
|
||||
"Depth": "infinity",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
require.FileExists(t, confidential)
|
||||
}
|
||||
|
||||
// TestServerRulesDestinationOverwriteDescendantsMultiDir is the same defect
|
||||
// across mounts, where the destination collection lives under another mount.
|
||||
func TestServerRulesDestinationOverwriteDescendantsMultiDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dirA := makeTestDirectory(t, map[string][]byte{"shared/protected/flag.txt": []byte("mount secret")})
|
||||
dirB := makeTestDirectory(t, map[string][]byte{"empty/.keep": []byte("")})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
permissions: CRUD
|
||||
directories:
|
||||
- name: alpha
|
||||
path: %s
|
||||
- name: beta
|
||||
path: %s
|
||||
rules:
|
||||
- path: "/alpha/shared/protected/"
|
||||
permissions: R
|
||||
`, dirA, dirB))
|
||||
defer srv.Close()
|
||||
|
||||
code, _ := doRequest(t, "MOVE", srv.URL+"/beta/empty/", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/alpha/shared/",
|
||||
"Overwrite": "T",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
code, _ = doRequest(t, "COPY", srv.URL+"/beta/empty/", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/alpha/shared/",
|
||||
"Depth": "infinity",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
require.FileExists(t, filepath.Join(dirA, "shared", "protected", "flag.txt"))
|
||||
}
|
||||
|
||||
// TestServerRulesDestinationOverwriteRequiresDelete covers the permission class
|
||||
// the overwrite is authorized under. Removing a collection is delete-class, so a
|
||||
// rule that grants writes but withholds D refuses the overwrite, whether it
|
||||
// governs the destination itself or something beneath it.
|
||||
func TestServerRulesDestinationOverwriteRequiresDelete(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"empty/.keep": []byte(""),
|
||||
"empty2/.keep": []byte(""),
|
||||
"nodelete/note.txt": []byte("kept by the rule on the collection"),
|
||||
"shared/keep/n.txt": []byte("kept by the rule on a descendant"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
rules:
|
||||
- path: "/nodelete/"
|
||||
permissions: CRU
|
||||
- path: "/shared/keep/"
|
||||
permissions: CRU
|
||||
`, dir))
|
||||
defer srv.Close()
|
||||
|
||||
// The rule governs the destination collection itself.
|
||||
code, _ := doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/nodelete/",
|
||||
"Overwrite": "T",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
require.FileExists(t, filepath.Join(dir, "nodelete", "note.txt"))
|
||||
|
||||
// The rule governs a descendant of the destination collection.
|
||||
code, _ = doRequest(t, "MOVE", srv.URL+"/empty2/", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/shared/",
|
||||
"Overwrite": "T",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusForbidden, code)
|
||||
require.FileExists(t, filepath.Join(dir, "shared", "keep", "n.txt"))
|
||||
}
|
||||
|
||||
// TestServerDestinationOverwriteAllowed pins what overwriting a destination is
|
||||
// still allowed to do, so the delete-class check on collections does not spread
|
||||
// to writing over a file. Replacing a file is update-class, the same class PUT
|
||||
// over an existing file needs, which clients rely on when they save by writing a
|
||||
// temporary file and moving it over the target.
|
||||
func TestServerDestinationOverwriteAllowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{
|
||||
"empty/.keep": []byte(""),
|
||||
"empty2/.keep": []byte(""),
|
||||
"plain/note.txt": []byte("plain"),
|
||||
"updatable/doc.txt": []byte("updatable"),
|
||||
"source.txt": []byte("source"),
|
||||
})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
rules:
|
||||
- path: "/updatable/doc.txt"
|
||||
permissions: RU
|
||||
`, dir))
|
||||
defer srv.Close()
|
||||
|
||||
// No rule withholds D anywhere under the destination.
|
||||
code, _ := doRequest(t, "MOVE", srv.URL+"/empty/", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/plain/",
|
||||
"Overwrite": "T",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusNoContent, code)
|
||||
|
||||
// A destination that does not exist is created, not overwritten.
|
||||
code, _ = doRequest(t, "MOVE", srv.URL+"/empty2/", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/fresh/",
|
||||
"Overwrite": "T",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusCreated, code)
|
||||
|
||||
// A file destination needs only the U its rule grants, not D.
|
||||
code, _ = doRequest(t, "MOVE", srv.URL+"/source.txt", "", "", map[string]string{
|
||||
"Destination": srv.URL + "/updatable/doc.txt",
|
||||
"Overwrite": "T",
|
||||
}, "")
|
||||
require.Equal(t, http.StatusNoContent, code)
|
||||
}
|
||||
|
||||
// TestServerLockRequiresWritePermission covers LOCK being authorized by any
|
||||
// permission at all, which let a read-only user create a file by locking a
|
||||
// missing path, and hold a write lock that blocks legitimate writers.
|
||||
func TestServerLockRequiresWritePermission(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{"existing.txt": []byte("x")})
|
||||
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: none
|
||||
users:
|
||||
- username: reader
|
||||
password: reader
|
||||
permissions: R
|
||||
- username: writer
|
||||
password: writer
|
||||
permissions: CRUD
|
||||
`, dir))
|
||||
defer srv.Close()
|
||||
|
||||
lock := func(username, path string) int {
|
||||
t.Helper()
|
||||
code, _ := doRequest(t, "LOCK", srv.URL+path, username, username,
|
||||
map[string]string{"Timeout": "Infinite", "Content-Type": "application/xml"}, exclusiveWriteLock)
|
||||
return code
|
||||
}
|
||||
|
||||
// A read-only user may neither lock a resource into existence nor reserve one.
|
||||
require.Equal(t, http.StatusForbidden, lock("reader", "/created-by-lock.txt"))
|
||||
require.NoFileExists(t, filepath.Join(dir, "created-by-lock.txt"))
|
||||
require.Equal(t, http.StatusForbidden, lock("reader", "/existing.txt"))
|
||||
|
||||
// Reading is unaffected.
|
||||
code, _ := doRequest(t, "GET", srv.URL+"/existing.txt", "reader", "reader", nil, "")
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
|
||||
// A user who can write still locks as before.
|
||||
require.Equal(t, http.StatusOK, lock("writer", "/existing.txt"))
|
||||
require.Equal(t, http.StatusCreated, lock("writer", "/new.txt"))
|
||||
}
|
||||
|
||||
// TestServerRulesCaseInsensitiveFilesystem covers a rule being evaded by asking
|
||||
// for a differently cased spelling of the same file.
|
||||
func TestServerRulesCaseInsensitiveFilesystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := makeTestDirectory(t, map[string][]byte{"secret/flag.txt": []byte("secret")})
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dir, "SECRET", "flag.txt")); err != nil {
|
||||
t.Skip("backing file system distinguishes path case")
|
||||
}
|
||||
|
||||
// A regex rule has to follow the file system too, or it is evaded the same
|
||||
// way while looking like it denies the path.
|
||||
for _, rule := range []string{`path: "/secret/"`, `regex: "^/secret/"`} {
|
||||
srv := makeTestServer(t, fmt.Sprintf(`
|
||||
directory: %s
|
||||
permissions: CRUD
|
||||
rules:
|
||||
- %s
|
||||
permissions: none
|
||||
`, dir, rule))
|
||||
|
||||
for _, path := range []string{"/secret/flag.txt", "/SECRET/flag.txt", "/Secret/flag.txt"} {
|
||||
code, _ := doRequest(t, "GET", srv.URL+path, "", "", nil, "")
|
||||
require.Equal(t, http.StatusForbidden, code, rule, path)
|
||||
|
||||
code, _ = doRequest(t, "DELETE", srv.URL+path, "", "", nil, "")
|
||||
require.Equal(t, http.StatusForbidden, code, rule, path)
|
||||
}
|
||||
|
||||
srv.Close()
|
||||
}
|
||||
|
||||
require.FileExists(t, filepath.Join(dir, "secret", "flag.txt"))
|
||||
}
|
||||
|
||||
func TestServerRulesEmptyPrefixDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ func writePartialUpdateError(w http.ResponseWriter, err error, fallbackStatus in
|
||||
|
||||
func (u *handlerUser) handleOptions(w http.ResponseWriter, r *http.Request, reqPath string) {
|
||||
allow := "OPTIONS, LOCK, PUT, MKCOL, PATCH"
|
||||
if fi, err := u.FileSystem.Stat(r.Context(), reqPath); err == nil {
|
||||
if fi, err := u.fs.Stat(r.Context(), reqPath); err == nil {
|
||||
if fi.IsDir() {
|
||||
allow = "OPTIONS, LOCK, DELETE, PROPPATCH, COPY, MOVE, UNLOCK, PROPFIND"
|
||||
} else {
|
||||
@@ -97,7 +97,7 @@ func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request
|
||||
defer release()
|
||||
|
||||
ctx := r.Context()
|
||||
fi, statErr := u.FileSystem.Stat(ctx, reqPath)
|
||||
fi, statErr := u.fs.Stat(ctx, reqPath)
|
||||
exists := statErr == nil
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
http.Error(w, statErr.Error(), http.StatusMethodNotAllowed)
|
||||
@@ -157,7 +157,7 @@ func (u *handlerUser) handlePartialUpdate(w http.ResponseWriter, r *http.Request
|
||||
if !exists {
|
||||
flag |= os.O_CREATE
|
||||
}
|
||||
f, err := u.FileSystem.OpenFile(ctx, reqPath, flag, 0666)
|
||||
f, err := u.fs.OpenFile(ctx, reqPath, flag, 0666)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
@@ -361,7 +361,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
|
||||
hdr := r.Header.Get("If")
|
||||
if hdr == "" {
|
||||
now := time.Now()
|
||||
token, err := u.LockSystem.Create(now, webdav.LockDetails{
|
||||
token, err := u.handler.LockSystem.Create(now, webdav.LockDetails{
|
||||
Root: src,
|
||||
Duration: -1,
|
||||
ZeroDepth: true,
|
||||
@@ -373,7 +373,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
|
||||
return nil, http.StatusInternalServerError, err
|
||||
}
|
||||
return func() {
|
||||
_ = u.LockSystem.Unlock(now, token)
|
||||
_ = u.handler.LockSystem.Unlock(now, token)
|
||||
}, 0, nil
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
|
||||
if parsedURL.Host != r.Host {
|
||||
continue
|
||||
}
|
||||
lsrc, err = stripPartialPrefix(parsedURL.Path, u.Prefix)
|
||||
lsrc, err = stripPartialPrefix(parsedURL.Path, u.handler.Prefix)
|
||||
if err != nil {
|
||||
return nil, http.StatusNotFound, err
|
||||
}
|
||||
@@ -401,7 +401,7 @@ func (u *handlerUser) confirmPartialUpdateLocks(r *http.Request, src string) (re
|
||||
lsrc = src
|
||||
}
|
||||
}
|
||||
release, err = u.LockSystem.Confirm(time.Now(), lsrc, "", l.conditions...)
|
||||
release, err = u.handler.LockSystem.Confirm(time.Now(), lsrc, "", l.conditions...)
|
||||
if errors.Is(err, webdav.ErrConfirmationFailed) {
|
||||
continue
|
||||
}
|
||||
|
||||
+78
-15
@@ -26,23 +26,38 @@ func (r *Rule) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Matches checks if [Rule] matches the given path.
|
||||
func (r *Rule) Matches(path string) bool {
|
||||
// Matches checks if [Rule] matches the given path. When caseInsensitive is set
|
||||
// the backing file system ignores case, so this must too. A regex is tried
|
||||
// against the folded path as well as the path as written, which only widens it
|
||||
// to spellings naming the same file.
|
||||
func (r *Rule) Matches(path string, caseInsensitive bool) bool {
|
||||
if r.Regex != nil {
|
||||
if caseInsensitive {
|
||||
return r.Regex.MatchString(path) || r.Regex.MatchString(foldPath(path))
|
||||
}
|
||||
|
||||
return r.Regex.MatchString(path)
|
||||
}
|
||||
|
||||
if caseInsensitive {
|
||||
return strings.HasPrefix(foldPath(path), foldPath(r.Path))
|
||||
}
|
||||
|
||||
return strings.HasPrefix(path, r.Path)
|
||||
}
|
||||
|
||||
// matchesCollection checks if [Rule] names path as the collection it governs,
|
||||
// such as a rule for "/c/" and a request for "/c". Regex rules are matched
|
||||
// literally and are not considered here.
|
||||
func (r *Rule) matchesCollection(path string) bool {
|
||||
func (r *Rule) matchesCollection(path string, caseInsensitive bool) bool {
|
||||
if r.Regex != nil || !strings.HasSuffix(r.Path, "/") {
|
||||
return false
|
||||
}
|
||||
|
||||
if caseInsensitive {
|
||||
return foldPath(path) == foldPath(strings.TrimSuffix(r.Path, "/"))
|
||||
}
|
||||
|
||||
return path == strings.TrimSuffix(r.Path, "/")
|
||||
}
|
||||
|
||||
@@ -63,6 +78,7 @@ type UserPermissions struct {
|
||||
directoryExplicit bool
|
||||
directoriesExplicit bool
|
||||
useDirectories bool
|
||||
caseInsensitive bool
|
||||
}
|
||||
|
||||
type DirectoryMount struct {
|
||||
@@ -93,26 +109,46 @@ func (p UserPermissions) Allowed(r *request, fileExists func(string) bool) bool
|
||||
// allowedAt resolves the permissions that govern path and applies check to them.
|
||||
func (p UserPermissions) allowedAt(path string, check func(Permissions) bool) bool {
|
||||
// Go through rules beginning from the last one. The first matched rule returns.
|
||||
// Both senses of matching are tested per rule: in separate passes a broader
|
||||
// rule would return first and shadow the narrower one naming the collection.
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
if p.Rules[i].Matches(path) {
|
||||
if p.Rules[i].Matches(path, p.caseInsensitive) {
|
||||
return check(p.Rules[i].Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
// A rule written with a trailing slash also governs the collection it names,
|
||||
// so that a rule for "/c/" cannot be evaded by asking for "/c". Such a request
|
||||
// acts on an entry of the parent collection, so it needs the permissions that
|
||||
// apply there too. Requiring both means the rule can restrict the collection
|
||||
// without granting access that would otherwise not exist.
|
||||
for i := len(p.Rules) - 1; i >= 0; i-- {
|
||||
if p.Rules[i].matchesCollection(path) {
|
||||
return check(p.Rules[i].Permissions) && check(p.Permissions)
|
||||
// A rule written with a trailing slash also governs the collection it names,
|
||||
// so a rule for "/c/" cannot be evaded by asking for "/c". Such a request acts
|
||||
// on an entry of the parent collection, so it needs those permissions too: the
|
||||
// rule can restrict the collection, not grant access that would not exist.
|
||||
if p.Rules[i].matchesCollection(path, p.caseInsensitive) {
|
||||
if !check(p.Rules[i].Permissions) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Through the rules, not the global permissions alone, which would deny
|
||||
// a collection that an enclosing rule grants.
|
||||
if parent := parentCollection(path); parent != path {
|
||||
return p.allowedAt(parent, check)
|
||||
}
|
||||
|
||||
return check(p.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
return check(p.Permissions)
|
||||
}
|
||||
|
||||
// parentCollection returns the collection containing path, such as "/data/" for
|
||||
// "/data/sub", or "/" for a top-level entry. That bounds allowedAt at the root.
|
||||
func parentCollection(p string) string {
|
||||
i := strings.LastIndex(strings.TrimSuffix(p, "/"), "/")
|
||||
if i <= 0 {
|
||||
return "/"
|
||||
}
|
||||
|
||||
return p[:i+1]
|
||||
}
|
||||
|
||||
func (p *UserPermissions) Validate() error {
|
||||
var err error
|
||||
|
||||
@@ -127,6 +163,8 @@ func (p *UserPermissions) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
p.caseInsensitive = p.hasCaseInsensitiveBacking()
|
||||
|
||||
for _, r := range p.Rules {
|
||||
if err := r.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid permissions: %w", err)
|
||||
@@ -143,6 +181,23 @@ func (p *UserPermissions) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasCaseInsensitiveBacking reports whether any backing directory resolves names
|
||||
// regardless of case. Mounts spread over volumes that differ all fold, which
|
||||
// keeps deny rules effective on the case-insensitive ones.
|
||||
func (p *UserPermissions) hasCaseInsensitiveBacking() bool {
|
||||
if p.useDirectories || len(p.Directories) > 0 {
|
||||
for _, mount := range p.Directories {
|
||||
if caseInsensitiveFS(mount.Path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return caseInsensitiveFS(p.Directory)
|
||||
}
|
||||
|
||||
func (d *DirectoryMounts) Validate() error {
|
||||
names := map[string]struct{}{}
|
||||
|
||||
@@ -237,8 +292,16 @@ func (p Permissions) Allowed(r *request, fileExists func(string) bool) bool {
|
||||
return p.Read && p.Delete
|
||||
case "DELETE":
|
||||
return p.Delete
|
||||
case "LOCK", "UNLOCK":
|
||||
return p.Create || p.Read || p.Update || p.Delete
|
||||
case "LOCK":
|
||||
// A lock is write-class: it reserves the resource against other writers,
|
||||
// and locking a path that does not exist creates it.
|
||||
if fileExists(r.path) {
|
||||
return p.Update
|
||||
} else {
|
||||
return p.Create
|
||||
}
|
||||
case "UNLOCK":
|
||||
return p.Create || p.Update
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user