Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand Down
71 changes: 67 additions & 4 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# → <RequireAny> Require claim ... (OAuth)
# users: "*" → Require valid-user
# users: "alice bob" → Require user alice bob (Basic)
# → Require ldap-user alice bob (LDAP)
# → <RequireAny> Require claim ... (OAuth)
# users: "* !charlie" → <RequireAll> valid-user + Require not user charlie
#
# Tokens prefixed with "!" are exclusions and generate "Require not" directives
# wrapped in a <RequireAll> 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 <RequireAll> + <RequireNone> for exclusions.
#
# "Require not user X" returns NEUTRAL for non-X users, which causes
# <RequireAll> to fail. <RequireNone> returns GRANTED when none of its
# inner directives match — the correct semantics for exclusion.
local claim="${OIDCRemoteUserClaim:-preferred_username}"
echo "${indent}<RequireAll>" >> "$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

# <RequireNone> returns GRANTED when none of its directives match,
# and DENIED when any matches — exactly the "exclude these users" logic.
echo "${indent} <RequireNone>" >> "$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} </RequireNone>" >> "$file"

echo "${indent}</RequireAll>" >> "$file"
return
fi

# No exclusions — original behaviour
if [ "$users" = "*" ]; then
echo "${indent}Require valid-user" >> "$file"
return
Expand Down Expand Up @@ -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
<Limit ${methods}>
EOF
Expand Down
35 changes: 31 additions & 4 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
84 changes: 83 additions & 1 deletion docs/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | — |
Expand All @@ -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:
Expand All @@ -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 `<RequireAll>` block with `Require valid-user` and one `Require not user <name>` per excluded user.

## Environment variables

| Variable | Default | Description |
Expand Down
55 changes: 55 additions & 0 deletions tests/scenario-1-basic-auth.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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