solid-pod-rs 0.4.0-alpha.1

Rust-native Solid Pod server library — LDP, WAC, WebID, Solid-OIDC, Solid Notifications, NIP-98. Framework-agnostic.
Documentation
# Security model

This page describes the threat model solid-pod-rs is built to defend
against, the controls the crate provides, and what remains the
integrator's responsibility.

## Scope

We defend against:

- Unauthenticated access to non-public resources.
- Authenticated-but-unauthorised access (WAC).
- Token replay (both NIP-98 and DPoP-bound OIDC).
- Path traversal / escape in storage backends.
- Request tampering on bodies (both dialects bind the body hash).

We **do not** (as a library) defend against:

- TLS termination weaknesses — that's the reverse proxy's job.
- Application-layer rate limiting.
- Denial of service from oversized requests — use your HTTP
  framework's body-size limits.
- OS-level integrity — if an attacker can write to `$POD_FS_ROOT`
  directly, they own the pod regardless of what the library does.

## Auth layering

Two layers, independent, stackable:

```mermaid
flowchart TD
    REQ["Inbound HTTP Request"] --> DOTFILE{"Dotfile<br/>allowlist?"}
    DOTFILE -->|blocked| R403A["403 Forbidden"]
    DOTFILE -->|pass| TRAV{"Path traversal<br/>guard?"}
    TRAV -->|"contains .. or null"| R403B["403 Forbidden"]
    TRAV -->|clean| SIZECAP{"ACL size<br/>cap?"}
    SIZECAP -->|"> 1 MiB"| R413["413 Payload Too Large"]
    SIZECAP -->|ok| AUTHTYPE{"Auth header<br/>present?"}

    AUTHTYPE -->|"Authorization: Nostr"| NIP98["NIP-98 verify<br/>kind 27235 + Schnorr"]
    AUTHTYPE -->|"Authorization: DPoP"| DPOP["DPoP proof verify<br/>+ access token"]
    AUTHTYPE -->|none| ANON["Anonymous<br/>(foaf:Agent only)"]

    NIP98 -->|"fail"| R401A["401 Unauthorized"]
    NIP98 -->|"ok"| IDENTITY["AuthContext<br/>(agent URI + modes)"]
    DPOP -->|"fail"| R401B["401 Unauthorized"]
    DPOP -->|"ok"| IDENTITY
    ANON --> IDENTITY

    IDENTITY --> WAC{"WAC evaluator<br/>deny-by-default"}
    WAC -->|"no matching ACL"| R403C["403 + WAC-Allow"]
    WAC -->|"mode granted"| LDP["LDP engine<br/>→ Storage"]

    style REQ fill:#4a90d9,stroke:#2c5f8a,color:#fff
    style LDP fill:#2ecc71,stroke:#1a9850,color:#fff
    style R403A fill:#e74c3c,stroke:#c0392b,color:#fff
    style R403B fill:#e74c3c,stroke:#c0392b,color:#fff
    style R403C fill:#e74c3c,stroke:#c0392b,color:#fff
    style R401A fill:#e74c3c,stroke:#c0392b,color:#fff
    style R401B fill:#e74c3c,stroke:#c0392b,color:#fff
    style R413 fill:#e74c3c,stroke:#c0392b,color:#fff
    style IDENTITY fill:#f39c12,stroke:#d68910,color:#fff
    style WAC fill:#9b59b6,stroke:#7d3c98,color:#fff
```

### Layer 1 — Request authentication

Either NIP-98 or Solid-OIDC DPoP. Both produce:

- A verified identity token (pubkey or WebID).
- A bound URL + method + optional body hash.

Characteristics:

| Property                        | NIP-98                          | Solid-OIDC DPoP                 |
|---------------------------------|---------------------------------|---------------------------------|
| Transport                       | HTTP `Authorization: Nostr …`   | HTTP `Authorization: DPoP …` + `DPoP: …` |
| Event / token format            | Nostr kind 27235 event, base64  | JWT access token + DPoP proof JWT |
| Signature algorithm             | Schnorr over secp256k1 (P1: structural only; P2: full) | ES256 / RS256 (access token + DPoP proof) |
| Binds URL                       | `u` tag                         | `htu` claim (DPoP proof)        |
| Binds method                    | `method` tag                    | `htm` claim                     |
| Binds body                      | `payload` tag = `SHA-256(body)` | Access-token handling; proof's `ath` if applicable |
| Timestamp tolerance             | ±60 s                           | Configurable `skew` (default 60 s) |
| Key-to-identity                 | pubkey → `did:nostr:{pubkey}`   | DPoP thumbprint bound via `cnf.jkt` |
| Anti-replay                     | Per-request timestamp window    | `jti` nonce cache (consumer-crate concern) |

Both layers produce an `agent_uri` string that feeds the WAC evaluator.

### Layer 2 — WAC authorisation

Once authenticated, every request is filtered by
`wac::evaluate_access`. The WAC evaluator:

- Walks up the tree looking for `.acl` sidecars.
- Parses JSON-LD authorizations.
- Checks agent matchers (`acl:agent`, `acl:agentClass`,
  `acl:agentGroup`).
- Checks mode (`acl:Read`, `Write`, `Append`, `Control`) with the
  single implication rule: `Write ⇒ Append`.
- Returns a boolean.

Deny-by-default: no ACL, no access.

## NIP-98 threat cases

### Token replay at a different URL

Mitigated. The `u` tag must match the canonical URL (trailing-slash
normalised). A token minted for `/profile/card` is rejected at
`/public/secrets`.

### Token replay at a different method

Mitigated. The `method` tag must match.

### Token replay with a modified body

Mitigated. If the body is non-empty, the `payload` tag must equal
`SHA-256(body)`. A token with no `payload` tag is rejected if a body
is provided.

### Token replay in the future

The 60 s window bounds replay. Clocks must be synced within that
window (NTP). We do **not** implement jti-cache — the timestamp
window is the only bound.

### Enlarged token to exhaust the server

Mitigated by `MAX_EVENT_SIZE = 64 KB`. Tokens larger than this (pre-
or post-base64-decode) are rejected without parsing.

### Non-standard kind

Mitigated. `kind != 27235` → rejected.

### Invalid pubkey format

Mitigated. 64 hex chars required; non-hex rejected.

## Solid-OIDC threat cases

### Bearer-token theft

Mitigated *at the protocol level* by DPoP: the client must prove
possession of the keypair whose thumbprint appears in the access
token's `cnf.jkt`. A stolen access token without the DPoP key is
useless.

### DPoP proof replay at a different URL / method

Mitigated. `htu` and `htm` claims are checked against the actual
request.

### DPoP proof replay in time

Mitigated by `iat` skew. We do not implement a jti nonce cache —
consumers that need stronger replay protection should add one in
middleware.

### Access-token substitution

Mitigated by issuer validation — `verify_access_token` enforces
`iss == expected_issuer`.

### WebID impersonation

Mitigated. `extract_webid` only accepts URL-shaped WebIDs from either
the `webid` claim (explicit) or the `sub` claim (fallback). Non-URL
`sub` values are rejected.

## WAC threat cases

### Modifying `.acl` without authorisation

`.acl` is a resource like any other, gated by the ACL effective for
**its own path**. Convention: granting `acl:Control` on a resource
permits writing its `.acl` sidecar. Our example server does not
special-case `.acl` writes — the HTTP layer must check
`AccessMode::Control` when the request URI ends in `.acl`.

### Walking up past the pod root

The resolver walks from the resource path up to `/`. It terminates at
`/`. There is no way to escape to the host filesystem.

### ACL document injection

`StorageAclResolver::find_effective_acl` silently ignores
deserialisation failures (treats them as "no ACL found"). This is the
safer default — a corrupted ACL must not accidentally grant access.
A noisier mode that raises `AclParse` would invite
denial-of-service via deliberately broken ACL documents.

## Storage threat cases

### Path traversal via `..`

Both built-in backends reject paths containing `..` or `\0` in
`normalize`. Custom backends **must** do the same.

### Path traversal via URL encoding

URL decoding happens in the HTTP framework, before solid-pod-rs sees
the path. Ensure the framework's URL-decoder is correct (actix-web,
axum, and hyper all handle this).

### Symlink escape (FS backend)

The `FsBackend::resolve` path check (`full.starts_with(root)`) is
best-effort — on filesystems that follow symlinks, a malicious
symlink inside the root could redirect writes. Run the pod as a user
with write access only to the pod root and nothing else.

### Concurrent mutation

Both backends are `Send + Sync` and use appropriate synchronisation.
Custom backends must preserve "either old or new state, never mid-
write" semantics for `put`.

## Sprint 12 security additions

### Size-capped ACL parsing (CWE-400)

`parse_turtle_acl_with_limit(input, max_bytes)` and
`parse_jsonld_acl_with_limits(body, max_bytes, max_depth)` reject
oversized ACL documents before parsing begins. The default cap is 1 MiB
(`MAX_ACL_BYTES`), tunable via `JSS_MAX_ACL_BYTES`. Rejection returns
`PodError::PayloadTooLarge`. This matches JSS's `safeJsonParse` pattern
which limits JSON body parsing to prevent memory-exhaustion DoS.

### `.account` dotfile allowlist entry

The `.account` dotfile is now permitted through the dotfile filter,
matching JSS commit `32c0db2`. This allows the IdP login endpoint at
`/.account/…` to serve account-related resources (login, registration,
password reset). Added to both `DEFAULT_ALLOWED` (struct-based) and
`STATIC_ALLOWED_DOTFILES` (free-function) allowlists, plus the config
schema's `default_dotfile_allowlist()`.

### Password-length validation (CWE-521)

The `solid-pod-rs-idp` crate enforces a minimum password length of 8
characters (matching JSS commit `1feead2`). `validate_password_length()`
is available as a standalone helper. Enforcement occurs at both
login (`LoginError::PasswordTooShort → HTTP 400`) and registration
(`UserStoreError::PasswordTooShort`) time.

### DNS resolution failure blocking

The SSRF guard blocks hosts that fail DNS resolution as defence-in-depth.
Hostnames under RFC 6761 reserved TLDs (e.g. `.invalid`) are blocked
rather than silently passed through. This prevents SSRF bypass via
attacker-controlled DNS records that point to internal IPs after initial
resolution.

## What integrators must add

### HTTP-layer hardening

- TLS termination with a strong cipher suite (TLS 1.3 only when
  possible).
- Body-size limits on every method (both NIP-98 limit ≤ 64 KB for
  the *token*; the *body* itself should be bounded realistically).
- Rate limiting per identity (not per IP — authenticated identity is
  the natural axis).
- Short request timeouts (PATCH blocks can pathologically evaluate).

### DPoP jti cache

Solid-OIDC DPoP nominally requires a short-TTL cache of seen `jti`
values to make replay protection strict. solid-pod-rs doesn't ship
one — it's a deployment concern (you choose Redis, in-memory, local
LRU).

### WebID-OIDC issuer trust

If you accept arbitrary OIDC issuers (not a single one), implement an
issuer allow-list. The crate's `verify_access_token` takes a single
`expected_issuer` argument — the caller decides which issuers are
accepted.

### Audit logging

Log every 401 / 403 with the identity that was rejected and the
resource path. Attackers probing for access leave patterns.

### ACL review pipeline

Treat `.acl` files as code. Review every change. A misplaced
`foaf:Agent` grants the world.

## Defence-in-depth recommendations

1. TLS everywhere. NIP-98 and DPoP both trust the transport.
2. Strong body caps (e.g., 10 MB per resource at the proxy).
3. Non-root pod process, with write access limited to the pod root.
4. No `public` network access on the pod port — traffic should come
   exclusively through the reverse proxy.
5. Rotate OIDC HS256 secrets if used (production should use ES256 /
   RS256 + JWKS, so rotation happens via the OP's JWKS endpoint).
6. Keep `RUST_LOG=solid_pod_rs=info` or stricter in production —
   `debug` may log token metadata in verbose contexts.

## See also

- [how-to/configure-nip98-auth.md]../how-to/configure-nip98-auth.md
- [how-to/enable-solid-oidc.md]../how-to/enable-solid-oidc.md
- [how-to/debug-acl-denials.md]../how-to/debug-acl-denials.md
- [reference/wac-modes.md]../reference/wac-modes.md
- [RFC 9449 DPoP]https://datatracker.ietf.org/doc/html/rfc9449
- [NIP-98]https://github.com/nostr-protocol/nips/blob/master/98.md