diff --git a/.env b/.env index cc96640..ce12d4d 100644 --- a/.env +++ b/.env @@ -11,14 +11,16 @@ SERVER_NAME=localhost # /path:users:mode # # path — folder path under the data directory (must start with /) -# users — "public" = no authentication required -# "*" = any authenticated user -# "alice bob" = specific users (space-separated) +# users — "public" = no authentication required +# "*" = any authenticated user +# "alice bob" = specific users (space-separated) +# "* !charlie" = any authenticated user except charlie # mode — "ro" = read-only (uses RO_METHODS) # "rw" = read-write (uses RW_METHODS) # -# Example: +# Examples: # FOLDER_PERMISSIONS="/public:public:ro,/shared:*:ro,/private:alice bob:rw,/admin:admin:rw" +# FOLDER_PERMISSIONS="/shared:* !charlie:ro,/private:alice bob:rw" # # Leave empty to use legacy single-root mode (controlled by *_ENABLED flags below). FOLDER_PERMISSIONS="/public:public:ro,/private:*:ro" diff --git a/README.md b/README.md index 9f07a95..ea20f47 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A lightweight, Docker-based WebDAV server built on Apache httpd with flexible pe - 🗂️ **Per-folder access control** — different folders can have different auth rules and user restrictions - 🌍 **Public folders** — mix unauthenticated and authenticated folders on the same server -- 👤 **Per-user permissions** — restrict specific folders to specific users +- 👤 **Per-user permissions** — include or exclude specific users per folder - 🔐 **Multiple auth methods** — Basic, LDAP, OAuth/OIDC (or LDAP + Basic combined) - ⚙️ **Configurable methods** — control read-only vs read-write access per folder - 🌐 **CORS support** — configurable for web clients @@ -36,11 +36,24 @@ The main configuration point. Controls which folders exist, who can access them, ```env # Format: "/path:users:mode" comma-separated -# users: public | * | alice bob (space-separated) +# users: public — no authentication required +# * — any authenticated user +# alice bob — specific users (space-separated) +# * !charlie — any authenticated user except charlie # mode: ro (uses RO_METHODS) | rw (uses RW_METHODS) FOLDER_PERMISSIONS="/public:public:ro,/shared:*:ro,/private:alice bob:rw,/admin:admin:rw" ``` +Prefix a username with `!` to exclude that user from an otherwise open folder: + +```env +# All authenticated users can read /shared except charlie +FOLDER_PERMISSIONS="/shared:* !charlie:ro" + +# Exclude multiple users +FOLDER_PERMISSIONS="/shared:* !charlie !dave:rw" +``` + Folders are auto-created at startup (`AUTO_CREATE_FOLDERS=true`). Leave `FOLDER_PERMISSIONS` empty to fall back to single-root mode (all paths, one auth method). diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 4201972..fdc57ff 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -57,16 +57,76 @@ EOF # --------------------------------------------------------------------------- # Write a Require line for a given users value and auth type. -# users: "*" → valid-user -# users: "alice bob" → Require user alice bob (Basic) -# → Require ldap-user alice bob (LDAP) -# → Require claim ... (OAuth) +# users: "*" → Require valid-user +# users: "alice bob" → Require user alice bob (Basic) +# → Require ldap-user alice bob (LDAP) +# → Require claim ... (OAuth) +# users: "* !charlie" → valid-user + Require not user charlie +# +# Tokens prefixed with "!" are exclusions and generate "Require not" directives +# wrapped in a block alongside the allow directive. # --------------------------------------------------------------------------- write_require() { local file="$1" local users="$2" local indent="$3" + # Split tokens into allow_list and exclude_list (! prefix = exclude). + # Disable glob expansion so a bare "*" in $users is not expanded to filenames. + local allow_list="" + local exclude_list="" + set -f + for u in $users; do + case "$u" in + !*) exclude_list="${exclude_list} ${u#!}" ;; + *) allow_list="${allow_list} ${u}" ;; + esac + done + set +f + allow_list="${allow_list# }" + exclude_list="${exclude_list# }" + + if [ -n "$exclude_list" ]; then + # Use + for exclusions. + # + # "Require not user X" returns NEUTRAL for non-X users, which causes + # to fail. returns GRANTED when none of its + # inner directives match — the correct semantics for exclusion. + local claim="${OIDCRemoteUserClaim:-preferred_username}" + echo "${indent}" >> "$file" + + # Allow directive (empty allow_list or "*" both mean any valid user) + if [ -z "$allow_list" ] || [ "$allow_list" = "*" ]; then + echo "${indent} Require valid-user" >> "$file" + elif [ "$LDAP_ENABLED" = "true" ]; then + echo "${indent} Require ldap-user ${allow_list}" >> "$file" + elif [ "$OAUTH_ENABLED" = "true" ]; then + for u in $allow_list; do + echo "${indent} Require claim ${claim}:${u}" >> "$file" + done + else + echo "${indent} Require user ${allow_list}" >> "$file" + fi + + # returns GRANTED when none of its directives match, + # and DENIED when any matches — exactly the "exclude these users" logic. + echo "${indent} " >> "$file" + for u in $exclude_list; do + if [ "$LDAP_ENABLED" = "true" ]; then + echo "${indent} Require ldap-user ${u}" >> "$file" + elif [ "$OAUTH_ENABLED" = "true" ]; then + echo "${indent} Require claim ${claim}:${u}" >> "$file" + else + echo "${indent} Require user ${u}" >> "$file" + fi + done + echo "${indent} " >> "$file" + + echo "${indent}" >> "$file" + return + fi + + # No exclusions — original behaviour if [ "$users" = "*" ]; then echo "${indent}Require valid-user" >> "$file" return @@ -118,6 +178,9 @@ EOF EOF else write_auth_directives "$FOLDERS_CONF" + # Return 403 (not 401) when a valid user is denied by authorization, + # so excluded users get a clear Forbidden rather than a re-auth challenge. + echo " AuthzSendForbiddenOnFailure On" >> "$FOLDERS_CONF" cat >> "$FOLDERS_CONF" << EOF EOF diff --git a/docs/examples.md b/docs/examples.md index c8c6a70..e82d34d 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -108,7 +108,34 @@ services: --- -## 5. 🏢 LDAP authentication +## 5. 🚫 Exclude specific users from a folder + +Allow all authenticated users to access a folder except one or more explicitly blocked users. + +```yaml +# docker-compose.yml +services: + webdav: + image: ghcr.io/vaggeliskls/webdav-server:latest + ports: + - "80:8080" + volumes: + - ./data:/var/lib/dav/data + environment: + SERVER_NAME: localhost + FOLDER_PERMISSIONS: "/shared:* !charlie:ro,/private:alice bob:rw" + AUTO_CREATE_FOLDERS: "true" + BASIC_AUTH_ENABLED: "true" + BASIC_USERS: "alice:alice123 bob:bob123 charlie:charlie123" +``` + +- `GET http://localhost/shared/` with `alice` or `bob` → allowed +- `GET http://localhost/shared/` with `charlie` → `403 Forbidden` +- Multiple exclusions: `"* !charlie !dave"` + +--- + +## 6. 🏢 LDAP authentication Authenticate users against an LDAP/Active Directory server. All authenticated users can access `/files`. @@ -135,7 +162,7 @@ services: --- -## 6. 🔀 LDAP with Basic Auth fallback +## 7. 🔀 LDAP with Basic Auth fallback Apache tries LDAP first. If LDAP is unreachable or the user is not found, it falls back to the local password file. @@ -164,7 +191,7 @@ services: --- -## 7. 🔁 Behind Traefik reverse proxy +## 8. 🔁 Behind Traefik reverse proxy Expose the server via Traefik with rate limiting. The `webdav` container is not directly port-exposed. @@ -209,7 +236,7 @@ networks: --- -## 8. 🧩 With CORS and health check +## 9. 🧩 With CORS and health check Enable CORS for web clients and expose a health check endpoint for uptime monitoring. diff --git a/docs/tests.md b/docs/tests.md index 899842b..fb38771 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -16,7 +16,7 @@ New scenario scripts are picked up automatically — no changes to `run-all.sh` | Script | What it tests | Extra dependencies | |--------|--------------|-------------------| -| [`tests/scenario-1-basic-auth.sh`](../tests/scenario-1-basic-auth.sh) | Public + Basic Auth private folder — full suite | — | +| [`tests/scenario-1-basic-auth.sh`](../tests/scenario-1-basic-auth.sh) | Public + Basic Auth private folder — full suite + user exclusion | — | | [`tests/scenario-2-readonly.sh`](../tests/scenario-2-readonly.sh) | All folders read-only — writes blocked | — | | [`tests/scenario-3-user-isolation.sh`](../tests/scenario-3-user-isolation.sh) | Per-user folder isolation — cross-access denied | — | | [`tests/scenario-4-public-only.sh`](../tests/scenario-4-public-only.sh) | No auth — public readable, PUT blocked | — | @@ -29,6 +29,74 @@ New scenario scripts are picked up automatically — no changes to `run-all.sh` Each script manages its own container lifecycle: it starts the container(s), waits for the health endpoint, runs the assertions, and cleans up on exit (even on failure). Scenario 5 also creates and removes a dedicated Docker network for LDAP communication. +### Scenario 1 — Basic Auth: public + private + +**Config:** `/public:public:ro,/private:alice bob:rw,/shared:*:ro` + +Runs the full [`test-security.sh`](../tests/test-security.sh) suite. Then spins up a dedicated container with `/shared:* !charlie:ro` to verify user exclusion in isolation: + +| Assertion | Expected | +|-----------|----------| +| `alice` (not excluded) `GET /shared/` | `200` or `207` | +| `bob` (not excluded) `GET /shared/` | `200` or `207` | +| `charlie` (excluded, valid credentials) `GET /shared/` | `403` | +| Unauthenticated `GET /shared/` | `401` | + +> The exclusion container is separate so charlie's credentials don't interfere with the main security suite. `403` (forbidden) distinguishes an authenticated-but-excluded user from `401` (unauthenticated). + +### Scenario 2 — Read-only folders + +**Config:** `/public:public:ro,/private:*:ro` + +Verifies that `ro` mode blocks all write methods even for authenticated users. + +| Assertion | Expected | +|-----------|----------| +| Unauthenticated request to `/private/` | `401` or `403` | +| Authenticated `GET /private/` | `200` or `207` | +| Authenticated `PUT /private/test.txt` | `403` or `405` | + +### Scenario 3 — Per-user folder isolation + +**Config:** `/alice:alice:rw,/bob:bob:rw,/shared:*:ro` + +Verifies that users can only access their own folders and cannot reach other users' folders. + +| Assertion | Expected | +|-----------|----------| +| `alice GET /alice/` | `200` or `207` | +| `bob GET /alice/` | `403` or `401` | +| `alice GET /bob/` | `403` or `401` | +| `alice GET /shared/` | `200` or `207` | +| `bob GET /shared/` | `200` or `207` | + +### Scenario 4 — Public server (no auth) + +**Config:** `/files:public:ro` + +Verifies that public folders work without credentials and that write methods are still blocked by `ro` mode. + +| Assertion | Expected | +|-----------|----------| +| `GET /files/` (no credentials) | `200` or `207` | +| `PUT /files/inject.txt` (no credentials) | `403` or `405` | + +### Scenario 5 — LDAP authentication + +**Config:** `/public:public:ro,/private:alice bob:rw,/alice:alice:rw,/bob:bob:rw` + +Spins up an `osixia/openldap` container alongside WebDAV on a shared Docker network, seeds two test users (`alice`, `bob`), then verifies LDAP credential validation and per-user isolation. + +| Assertion | Expected | +|-----------|----------| +| No credentials on `/private/` | `401` or `403` | +| Wrong LDAP password | `401` or `403` | +| Non-existent LDAP user | `401` or `403` | +| `alice` with correct LDAP password — `GET /private/` | `200` or `207` | +| `alice` `PUT` to `/private/` | `201` or `204` | +| `bob GET /alice/` | `401` or `403` | +| `alice GET /bob/` | `401` or `403` | + ## Full security suite [`tests/test-security.sh`](../tests/test-security.sh) is used by scenario 1 and can also be pointed at any already-running server: @@ -49,6 +117,20 @@ Covers: - Security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`) - Health check endpoint +## Permission model + +Access control uses an **allowlist with optional exclusions**. The `users` field in `FOLDER_PERMISSIONS` controls who can access a folder: + +| Value | Meaning | +|-------|---------| +| `public` | No authentication required | +| `*` | Any authenticated user | +| `alice bob` | Only the named users (space-separated) | +| `* !charlie` | Any authenticated user except `charlie` | +| `* !charlie !dave` | Any authenticated user except `charlie` and `dave` | + +Prefix a username with `!` to exclude that specific user. Exclusions are only meaningful alongside `*` (all users). Under the hood, exclusions generate a `` block with `Require valid-user` and one `Require not user ` per excluded user. + ## Environment variables | Variable | Default | Description | diff --git a/tests/scenario-1-basic-auth.sh b/tests/scenario-1-basic-auth.sh index 62acd8e..b88a2ec 100755 --- a/tests/scenario-1-basic-auth.sh +++ b/tests/scenario-1-basic-auth.sh @@ -68,3 +68,58 @@ if [ "$EXIT_CODE" -ne 0 ]; then dump_logs exit "$EXIT_CODE" fi + +# --------------------------------------------------------------------------- +# User exclusion tests +# Dedicated container with "* !charlie" on /shared. +# alice and bob are allowed; charlie is authenticated but excluded (403). +# --------------------------------------------------------------------------- +EXCL_CONTAINER="webdav-scenario-1-excl" +docker rm -f "$CONTAINER" "$EXCL_CONTAINER" >/dev/null 2>&1 || true + +docker run -d --name "$EXCL_CONTAINER" \ + -p "${PORT}:8080" \ + -e SERVER_NAME=localhost \ + -e "FOLDER_PERMISSIONS=/shared:* !charlie:ro" \ + -e AUTO_CREATE_FOLDERS=true \ + -e BASIC_AUTH_ENABLED=true \ + -e BASIC_USERS="alice:alice123 bob:bob123 charlie:charlie123" \ + -e HEALTH_CHECK_ENABLED=true \ + "$IMAGE" + +CONTAINER_NAME="$EXCL_CONTAINER" +trap 'teardown_container' EXIT INT TERM +wait_for_server "${BASE_URL}/_health" + +print_header "User exclusion — * !charlie on /shared" + +assert_status_in \ + "alice (not excluded) can GET /shared/" \ + "200 207" \ + -u alice:alice123 \ + "${BASE_URL}/shared/" + +assert_status_in \ + "bob (not excluded) can GET /shared/" \ + "200 207" \ + -u bob:bob123 \ + "${BASE_URL}/shared/" + +assert_status_in \ + "charlie (excluded, valid credentials) is forbidden /shared/" \ + "403" \ + -u charlie:charlie123 \ + "${BASE_URL}/shared/" + +assert_status_in \ + "unauthenticated request is unauthorised /shared/" \ + "401" \ + "${BASE_URL}/shared/" + +# --------------------------------------------------------------------------- +print_summary + +if [ "$FAIL" -gt 0 ]; then + dump_logs + exit 1 +fi