feat(magic-links): add rate limiting + archirecture documentation
This commit is contained in:
@@ -116,6 +116,7 @@ export default defineConfig({
|
||||
{ text: "File and Blob lifecycle", link: "/architecture/file-and-blob-lifecycle" },
|
||||
{ text: "ReBAC & Authorization", link: "/architecture/rebac-authorization" },
|
||||
{ text: "User lifecycle", link: "/architecture/user-lifecycle" },
|
||||
{ text: "Magic-link auth", link: "/architecture/magic-link-auth" },
|
||||
],
|
||||
},
|
||||
{ text: "FAQ", link: "/faq" },
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
# Magic-Link External Authentication
|
||||
|
||||
OxiCloud supports sharing resources with people who do not yet have an account on the instance, via per-email invitations and per-email sign-in links. Recipients are provisioned lazily as **external users** and authenticate exclusively through one-time URLs delivered by email, until they later set a password or link an OIDC identity.
|
||||
|
||||
This page is the architectural overview. For configuration knobs, see [Environment Variables](/config/env). For how grants are evaluated, see [ReBAC Authorization](/architecture/rebac-authorization). For how shares relate to grants, see [Share Integration](/architecture/share-integration).
|
||||
|
||||
## Why this exists
|
||||
|
||||
Two scenarios are not covered by username/password or OIDC:
|
||||
|
||||
1. **Sharing with someone who is not yet an OxiCloud user.** The sharer should be able to type an email address into the share modal and let the server handle the rest.
|
||||
2. **A pre-existing external user has lost their bookmark.** They never had a password — the only way to get back in is a fresh magic link to the address on file.
|
||||
|
||||
Both are handled by the same magic-link primitive: a single-use, time-limited token issued out of band (by email) that exchanges for a session on redemption.
|
||||
|
||||
## Two flows
|
||||
|
||||
```
|
||||
Invitation flow Login-via-email flow
|
||||
─────────────── ────────────────────
|
||||
Alice fills share modal Bob hits /login, types his email
|
||||
│ │
|
||||
POST /api/grants POST /api/auth/magic-link/send
|
||||
{ subject.type: "email" } { email: "bob@example.com" }
|
||||
│ │
|
||||
resolve_or_create_recipient find user by email
|
||||
├─ found → reuse (no creation here)
|
||||
└─ new → User::new_external │
|
||||
│ │
|
||||
mint token (resource_type/id set) mint token (NULL resource)
|
||||
│ │
|
||||
queue invitation email queue sign-in email
|
||||
│ │
|
||||
return GrantDto (201) return uniform 200
|
||||
│ │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│
|
||||
recipient clicks /magic/v1/{token}
|
||||
│
|
||||
validate, mark used, issue cookies
|
||||
│
|
||||
┌──────────────┴──────────────────────────┐
|
||||
↓ ↓
|
||||
Redirect to /#/files/folder/{id} Redirect to /#/sharedwithme
|
||||
(resource target) (no resource target)
|
||||
```
|
||||
|
||||
The same redemption endpoint serves both — the only difference is the landing redirect, which is decided by whether `magic_link_tokens.resource_id IS NULL`.
|
||||
|
||||
## Identity model for external users
|
||||
|
||||
A user is **magic-link-eligible** if and only if they have no other authentication method configured. The single source of truth is `User::has_login_credential()`:
|
||||
|
||||
| State | `password_hash` | `oidc_subject` | Eligible? |
|
||||
|--------------------------------|------------------------------|----------------|-----------|
|
||||
| External, freshly invited | `__EXTERNAL_NO_PASSWORD__` | NULL | yes |
|
||||
| External who set a password | real Argon2 hash | NULL | no |
|
||||
| External who linked OIDC | `__OIDC_NO_PASSWORD__` | set | no |
|
||||
| Internal, password | real Argon2 hash | NULL | no |
|
||||
| Internal, OIDC-only | `__OIDC_NO_PASSWORD__` | set | no |
|
||||
|
||||
The placeholder strings (`__EXTERNAL_NO_PASSWORD__`, `__OIDC_NO_PASSWORD__`) are an acknowledged smell. A future refactor introduces an `auth.user_auth_methods` side-table with one row per `(user_id, method_type)`; the migration touches the body of `has_login_credential()` only.
|
||||
|
||||
The eligibility rule rules out one specific bypass: an internal user with a password cannot be signed in via a magic link sent to their mailbox. Mailbox ownership is not a substitute for the password — that distinction matters when mailboxes are easier to compromise than passwords (mail-forwarding rules, shared aliases, etc.).
|
||||
|
||||
### Username and display
|
||||
|
||||
- External users get `username = normalised_email`. Login forms accept username OR email; lookup tries `username` first, falls back to `email`.
|
||||
- The `auth.users.username` column was widened from 32 to 254 chars (RFC 5321 maximum) when this work landed.
|
||||
- `auth.users.given_name` and `auth.users.family_name` are `TEXT NULL` — populated from OIDC claims at JIT provisioning; external users get NULL initially and can fill them in later.
|
||||
- Home folder name (`"My Folder - alice"`) is **not** renamed when username changes — it was display text at creation; the folder is semantically owned by `user_id`.
|
||||
|
||||
### Email normalisation
|
||||
|
||||
Every email crossing the boundary into the DB or a rate-limit key goes through `domain::services::email_normalize::normalize_email`:
|
||||
|
||||
1. Trim whitespace.
|
||||
2. Split on the **last** `@`.
|
||||
3. Lowercase the local part.
|
||||
4. Punycode-encode the domain via `idna::domain_to_ascii`.
|
||||
|
||||
So `Alice@Example.COM`, ` alice@example.com `, and `alice@münchen.de` all map to a stable ASCII form before storage or comparison. Gmail's `+tag` and `.` insensitivities are deliberately **not** special-cased — addresses are treated as opaque strings post-normalisation.
|
||||
|
||||
## Token lifecycle
|
||||
|
||||
```
|
||||
┌─────────┐
|
||||
(insert) ─────► │ pending │ ─── redeem ──► ┌──────┐
|
||||
│ │ │ used │
|
||||
└────┬────┘ └──────┘
|
||||
│
|
||||
(sweeper, TTL)
|
||||
↓
|
||||
┌─────────┐
|
||||
│ expired │
|
||||
└─────────┘
|
||||
```
|
||||
|
||||
`auth.magic_link_tokens` mirrors `auth.device_codes` exactly: PostgreSQL ENUM status, 32-byte CSPRNG token in base64url, single-use via `UPDATE … WHERE status = 'pending'`, partial index on `expires_at WHERE pending`, and a background sweeper that promotes pending-and-overdue rows to expired.
|
||||
|
||||
Salient properties:
|
||||
|
||||
- **Single-use** — second redemption attempt rejected as "link already used".
|
||||
- **TTL-enforced** — `expires_at < NOW()` → "link expired". TTL is `OXICLOUD_MAGIC_LINK_TTL_HOURS`, default 24.
|
||||
- **Token in path, not query** — `GET /magic/v1/{token}` so the secret stays out of `Referer` headers.
|
||||
- **302 immediately on success** — the URL is replaced in the address bar before the user can navigate away or screenshot it.
|
||||
- **Optional resource target** — `resource_type` + `resource_id` columns, with a `CHECK ((resource_type IS NULL) = (resource_id IS NULL))` constraint to make the two-or-neither rule explicit.
|
||||
|
||||
## User-profile visibility rule (`GET /api/users/{id}`)
|
||||
|
||||
The endpoint is the cornerstone of the share modal's "who is this person" rendering. Its visibility rule is intentionally narrow, evaluated in this order:
|
||||
|
||||
1. **Self** — caller asks for their own profile.
|
||||
2. **Shared-grant relationship** — caller and target share at least one access grant in either direction. Applies to internal AND external callers; this is what lets a recipient resolve the granter's name/photo in the SharedWithMe view.
|
||||
3. **External lockout** — if the caller is external and rule 2 did not match, stop and return 404.
|
||||
4. **Directory exposure** — if the target is internal AND `OXICLOUD_EXPOSE_SYSTEM_USERS=true`, return the target.
|
||||
5. **Admin** — admins can always look up any user.
|
||||
6. **404** — otherwise. Same response as "user does not exist" (anti-enumeration).
|
||||
|
||||
A per-caller sliding-window rate limit (60 req/minute) guards against an attacker iterating UUIDs against rule 2 with a stale JWT. The visibility rule alone is sufficient defence-in-principle; the rate limit makes the attack uneconomical.
|
||||
|
||||
## Audit events
|
||||
|
||||
Every denial or rejection in the magic-link path emits a structured event on the `audit` tracing target. Operators tail `target=audit` for compliance and incident response.
|
||||
|
||||
| Event | Reasons (subset) | Where it fires |
|
||||
|------------------------------------|---------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| `authz.denied` | permission missing | `AuthorizationEngine::require` |
|
||||
| `auth.login` | `user_not_found`, `bad_password`, `account_deactivated` | `AuthApplicationService::login` |
|
||||
| `auth.magic_link_send` | `sent`, `no_account`, `has_credential`, `account_deactivated`, `malformed_email`, `rate_limited_ip`, `rate_limited_email` | `MagicLinkInviteService::send_login_link` and the handler |
|
||||
| `auth.magic_link_redeem` | `redeemed`, `token_not_found`, `token_used`, `token_expired`, `account_deactivated` | `MagicLinkInviteService::redeem` |
|
||||
| `user_profile.rejected` | `external_no_relationship`, `target_external_hidden`, `target_hidden` | `AuthApplicationService::get_user_profile` |
|
||||
| `grants.email_invite` | `rate_limited` | `grant_handler::create_grant` |
|
||||
|
||||
The convention (see CLAUDE.md § Authorization) is: any branch that denies or rejects a request **must** emit an audit event before returning the user-facing response. Anti-enumeration is preserved at the API surface (uniform response shape, 404 not 403), and the true reason is recorded only in the audit channel.
|
||||
|
||||
## Rate limits
|
||||
|
||||
Three caps protect the magic-link surface. Each is a moka sliding-window counter; the keys differ.
|
||||
|
||||
| Cap | Keyed on | Default | Env var | Visible on hit? |
|
||||
|----------------------------------------------|--------------------------------|-----------|------------------------------------------------------|------------------|
|
||||
| Per-sharer email-invite | `caller_id` | 50 / hour | `OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR` | yes (429 + `Retry-After`) |
|
||||
| Per-target-email send | normalised email | 5 / hour | `OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR` | no (uniform 200) |
|
||||
| Per-source-IP send (backstop) | trusted client IP | 200 / hour | `OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR` | no (uniform 200) |
|
||||
|
||||
Two distinct visibility regimes:
|
||||
|
||||
- **Authenticated callers** see 429 when they hit a cap, because their own rate-limit state leaks nothing about other accounts. The invite cap is in this regime.
|
||||
- **Anonymous callers** never see 429 on the send endpoint — the status itself would become an enumeration oracle ("this email has been probed recently → probably has an account"). The two send caps are silently absorbed: the response stays the uniform 200, and the real reason is recorded in the audit channel.
|
||||
|
||||
An authenticated caller resending to themselves bypasses both send caps. The bypass signal is "presence (not validity) of an Authorization header or access cookie" — a stale-cookie holder gets a 401 from any other endpoint they touch, so the worst-case bypass is narrow.
|
||||
|
||||
The per-IP backstop respects `OXICLOUD_TRUST_PROXY_CIDR` for client IP resolution: behind a reverse proxy, the upstream IP from `X-Forwarded-For` (leftmost) is used; without a configured trusted CIDR, the proxy's IP would be used and the backstop would effectively become a single bucket.
|
||||
|
||||
## Defence-in-depth boundary protections
|
||||
|
||||
External users are a new principal kind, and several pre-existing surfaces would over-share once they appeared. Three protections close those gaps:
|
||||
|
||||
1. **Subject groups reject external members.** `subject_group_service.rs::add_member` short-circuits if the candidate user has `is_external = TRUE`. Otherwise an admin could add `alice@example.com` to "Engineering", which later receives a grant on internal-only resources — silent privilege escalation. Mirrors the no-external-admins enforcement.
|
||||
2. **System contacts hide externals by default.** `auth_service.list_users` and `auth_service.search_users` take `include_external: bool`, defaulting to `false`. The share modal autocomplete (via `/api/address-books/system/contacts`) therefore never surfaces external users to internal callers, and external users never see internal users at the address book layer.
|
||||
3. **External users are excluded from the Internal virtual group.** `pg_acl_engine.rs::expand_user` no longer inserts `INTERNAL_GROUP_ID` for users with `is_external = TRUE`. The group's name finally honours its semantics; every grant addressed to "all internal users" is now genuinely internal-only.
|
||||
|
||||
These three protections all activate at the **service layer**, so every protocol surface (REST, WebDAV, CalDAV, NextCloud) inherits them automatically.
|
||||
|
||||
Pre-existing safeguards from the user-lifecycle work continue to apply: the DB CHECK constraints `users_external_not_admin` and `users_external_no_storage`, and the `HomeFolderLifecycleHook` short-circuit that skips home-folder provisioning for externals.
|
||||
|
||||
## Kill switches and feature scoping
|
||||
|
||||
| Knob | What it does |
|
||||
|----------------------------------------|--------------------------------------------------------------------------------------------------------|
|
||||
| `OXICLOUD_ALLOW_EXTERNAL_USERS=false` | Coarse off-switch. `POST /api/grants` rejects email-typed subjects for unknown emails; send endpoint returns the uniform stub without issuing a token. Pre-existing externals continue to function. |
|
||||
| `OXICLOUD_EXTERNAL_EMAIL_DOMAINS=…` | Fine-grained allowlist of accepted domains for new external users. Empty = no restriction. Exact-match (case-insensitive) on the post-`@` part — `partner.com` does NOT match `eng.partner.com`. |
|
||||
| `OXICLOUD_SMTP_*` unconfigured | The whole magic-link feature is unavailable. Endpoints that depend on it return `503 Service Unavailable` with a clear message. |
|
||||
| `OXICLOUD_MAGIC_LINK_TTL_HOURS` | Token lifetime. Default 24 hours. Shortening it raises the resend rate; lengthening it raises the window for token theft. |
|
||||
|
||||
The send endpoint **does** return 503 (not the uniform 200) when SMTP is entirely unconfigured: the absence of the feature is visible from any other `/api/auth/magic-link/*` route anyway, so hiding the 503 leaks nothing the attacker could not learn elsewhere.
|
||||
|
||||
## What is deliberately out of scope
|
||||
|
||||
These are intentionally deferred. Each has a clear future trigger; none block the present design.
|
||||
|
||||
- **`auth.user_auth_methods` side-table.** Replaces the placeholder-string smell. `has_login_credential()` is the single migration point.
|
||||
- **Email-locale routing.** v1 ships English-only invitation templates. A future PR adds recipient-locale detection (Accept-Language at send time, or stored preference) and a template engine.
|
||||
- **MX-record validation at share time.** Regex is the only pre-send check; bad domains surface via SMTP bounce.
|
||||
- **Dormant external user sweeper.** Purges users with no `last_login_at` for 13+ months. The GDPR-purge variant in `UserLifecycleHook::on_user_deleted` is its hook entry point.
|
||||
- **`OXICLOUD_EXTERNAL_USERS_CAN_RESHARE=false`.** Forbids externals from being a grant's `granted_by`. Today an external with `Permission::Share` can mint more externals — a soft policy worth tightening but not load-bearing.
|
||||
- **Differentiated session TTL for externals.** Uniform across all users today. Future env: `OXICLOUD_EXTERNAL_REFRESH_TOKEN_EXPIRY_DAYS`.
|
||||
- **`session_kind` on sessions emitted from magic-link.** Enables scoped sessions ("magic-link session can only access granted resources, not the user's own folders"). External users have no own folders so the practical exposure is small.
|
||||
- **Admin-list-users that includes externals.** Today `list_users` filters externals by default. A future admin UI for managing externals (rename, deactivate, view their grants) will need an `include_external` query param.
|
||||
- **Open Cloud Mesh (OCM) federation.** A separate path for external identity; `ExternalIdentityLifecycleHook::on_user_created` accommodates a `source` discriminator (`magic_link` / `oidc` / `ocm`).
|
||||
- **WebAuthn / passkey enrolment.** Distinct future feature; magic-link is the bootstrap.
|
||||
- **Bounce tracking.** No webhook listener for SES-style bounce notifications. A future `on_email_bounce` event would surface "this user's email is dead" in admin UI.
|
||||
|
||||
## Related documents
|
||||
|
||||
- [User lifecycle](/architecture/user-lifecycle) — the hook framework that fires on user creation and the deletion modes.
|
||||
- [ReBAC Authorization](/architecture/rebac-authorization) — how grants are evaluated against `auth.users` rows (including external ones).
|
||||
- [Share Integration](/architecture/share-integration) — how the public-share-link flow relates to the email-invite flow (both create `access_grants` rows; only the former lives in `storage.shares`).
|
||||
- [Environment Variables](/config/env) — the full set of `OXICLOUD_*` knobs.
|
||||
@@ -4,6 +4,8 @@ OxiCloud supports public file and folder sharing through signed share links. A s
|
||||
|
||||
> **Where permission and expiration live now.** Both the granted permissions and the expiration timestamp are stored on the `storage.access_grants` row that represents the share, not on the share row itself. They are evaluated by the same `AuthorizationEngine` that handles user and group grants — see [ReBAC Authorization](/architecture/rebac-authorization). The `storage.shares` row keeps only the token-side metadata (public token, password hash, item name, access count).
|
||||
|
||||
> **Sharing with people who do not yet have an account.** Token-based shares are anonymous; anyone with the URL can use them. To share with a specific person who isn't on the instance yet, the share modal accepts a raw email address and provisions the recipient as an *external user* on the fly. That flow is described in [Magic-link external authentication](/architecture/magic-link-auth), and the resulting grant is a regular per-user `access_grants` row — identical in evaluation to a grant on an internal recipient.
|
||||
|
||||
## What a Share Contains
|
||||
|
||||
A share record (`storage.shares`) tracks:
|
||||
|
||||
+20
@@ -382,6 +382,26 @@ OXICLOUD_WOPI_ENABLED=false
|
||||
# Example (only addresses on these two domains can be invited):
|
||||
#OXICLOUD_EXTERNAL_EMAIL_DOMAINS=partner-a.com,partner-b.io
|
||||
|
||||
# Per-sharer rate limit on email-type grants from POST /api/grants. Keyed on
|
||||
# the authenticated caller's user_id. Hitting the cap returns 429 with
|
||||
# Retry-After. Default 50/hour — generous for legitimate admin invites,
|
||||
# protective against a compromised account spamming external users.
|
||||
#OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=50
|
||||
|
||||
# Per-target-email rate limit on POST /api/auth/magic-link/send. Keyed on
|
||||
# the normalised recipient address (lowercased local, punycode domain).
|
||||
# Exceeding the cap is silently absorbed (uniform 200 anti-enumeration);
|
||||
# audit log records the real reason. Authenticated callers bypass this
|
||||
# limit (a logged-in user resending to themselves should not be throttled).
|
||||
# Default 5/hour.
|
||||
#OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=5
|
||||
|
||||
# Per-source-IP backstop on POST /api/auth/magic-link/send. Bounds the cost
|
||||
# of one attacker spreading 5/hr requests across many target addresses.
|
||||
# Same silently-absorbed behaviour on cap. Honours OXICLOUD_TRUST_PROXY_CIDR
|
||||
# for client IP resolution. Default 200/hour.
|
||||
#OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=200
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# PROXY
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+39
-6
@@ -714,6 +714,21 @@ pub struct MagicLinkConfig {
|
||||
/// `partner.com` does NOT match `eng.partner.com`. List every
|
||||
/// subdomain explicitly.
|
||||
pub allowed_email_domains: Vec<String>,
|
||||
/// Per-sharer ceiling on email-typed grant invitations from
|
||||
/// `POST /api/grants`. Keyed on `caller_id`. Exceeding the ceiling
|
||||
/// returns 429. Default: 50/hour.
|
||||
pub invite_per_caller_per_hour: u32,
|
||||
/// Per-target-email ceiling on `POST /api/auth/magic-link/send`,
|
||||
/// keyed on the normalised recipient address. Anti-bombing.
|
||||
/// Exceeding the ceiling is silently absorbed (uniform 200) so
|
||||
/// the response shape can't be used as an enumeration oracle.
|
||||
/// Default: 5/hour.
|
||||
pub send_per_email_per_hour: u32,
|
||||
/// Per-source-IP backstop on `POST /api/auth/magic-link/send`,
|
||||
/// keyed on the trusted client IP. Bounds the cost of an attacker
|
||||
/// spreading low per-email volume across many target addresses.
|
||||
/// Default: 200/hour.
|
||||
pub send_per_ip_per_hour: u32,
|
||||
}
|
||||
|
||||
impl Default for MagicLinkConfig {
|
||||
@@ -722,6 +737,9 @@ impl Default for MagicLinkConfig {
|
||||
ttl_hours: 24,
|
||||
allow_external_users: true,
|
||||
allowed_email_domains: Vec::new(),
|
||||
invite_per_caller_per_hour: 50,
|
||||
send_per_email_per_hour: 5,
|
||||
send_per_ip_per_hour: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1350,6 +1368,24 @@ impl AppConfig {
|
||||
.filter(|d| !d.is_empty())
|
||||
.collect();
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
&& n > 0
|
||||
{
|
||||
config.magic_link.invite_per_caller_per_hour = n;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
&& n > 0
|
||||
{
|
||||
config.magic_link.send_per_email_per_hour = n;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR")
|
||||
&& let Ok(n) = v.parse::<u32>()
|
||||
&& n > 0
|
||||
{
|
||||
config.magic_link.send_per_ip_per_hour = n;
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
@@ -1410,9 +1446,8 @@ mod tests {
|
||||
#[test]
|
||||
fn allowlist_matches_case_insensitively() {
|
||||
let cfg = MagicLinkConfig {
|
||||
ttl_hours: 24,
|
||||
allow_external_users: true,
|
||||
allowed_email_domains: vec!["partner-a.com".to_string(), "partner-b.io".to_string()],
|
||||
..MagicLinkConfig::default()
|
||||
};
|
||||
assert!(cfg.is_email_allowed("alice@partner-a.com"));
|
||||
// Uppercase domain in the email — must still match.
|
||||
@@ -1425,9 +1460,8 @@ mod tests {
|
||||
#[test]
|
||||
fn allowlist_does_not_match_subdomains_implicitly() {
|
||||
let cfg = MagicLinkConfig {
|
||||
ttl_hours: 24,
|
||||
allow_external_users: true,
|
||||
allowed_email_domains: vec!["partner.com".to_string()],
|
||||
..MagicLinkConfig::default()
|
||||
};
|
||||
assert!(cfg.is_email_allowed("alice@partner.com"));
|
||||
// Subdomain must be listed explicitly — exact match only.
|
||||
@@ -1439,9 +1473,8 @@ mod tests {
|
||||
#[test]
|
||||
fn malformed_email_fails_closed() {
|
||||
let cfg = MagicLinkConfig {
|
||||
ttl_hours: 24,
|
||||
allow_external_users: true,
|
||||
allowed_email_domains: vec!["partner.com".to_string()],
|
||||
..MagicLinkConfig::default()
|
||||
};
|
||||
// No `@` — rejected even though allowlist is set.
|
||||
assert!(!cfg.is_email_allowed("not-an-email"));
|
||||
|
||||
@@ -932,6 +932,38 @@ impl AppServiceFactory {
|
||||
user_profile_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(60, 60, 50_000),
|
||||
),
|
||||
// PR 12 — per-sharer email-invite ceiling: caller_id-keyed.
|
||||
// Defends against a compromised account spamming external
|
||||
// invites (each invite mints a new external user + email).
|
||||
// Limits come from MagicLinkConfig so tests / operators can
|
||||
// tune them via OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR.
|
||||
email_invite_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(
|
||||
self.config.magic_link.invite_per_caller_per_hour,
|
||||
3_600,
|
||||
50_000,
|
||||
),
|
||||
),
|
||||
// PR 12 — per-target-email send ceiling on
|
||||
// /api/auth/magic-link/send. Stops the endpoint from being
|
||||
// an email-bombing primitive against a known address.
|
||||
magic_link_send_per_email_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(
|
||||
self.config.magic_link.send_per_email_per_hour,
|
||||
3_600,
|
||||
50_000,
|
||||
),
|
||||
),
|
||||
// PR 12 — per-IP backstop on /api/auth/magic-link/send.
|
||||
// Bounds the damage if an attacker spreads a low per-email
|
||||
// rate across many target addresses.
|
||||
magic_link_send_per_ip_rate_limiter: Arc::new(
|
||||
crate::interfaces::middleware::rate_limit::RateLimiter::new(
|
||||
self.config.magic_link.send_per_ip_per_hour,
|
||||
3_600,
|
||||
50_000,
|
||||
),
|
||||
),
|
||||
};
|
||||
let email_bundle = build_email_sender(&self.config.smtp);
|
||||
app_state.email_sender = email_bundle.sender;
|
||||
@@ -1316,6 +1348,26 @@ pub struct AppState {
|
||||
/// authenticated caller covers any legitimate UI rendering while
|
||||
/// throttling enumeration.
|
||||
pub user_profile_rate_limiter: Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
|
||||
/// Per-sharer ceiling on `POST /api/grants` invitations whose
|
||||
/// subject is `{ type: "email" }`. 50 per hour keyed on
|
||||
/// `caller_id`. Anonymous attackers can't reach this code path
|
||||
/// (the route is auth-protected); this defends against a
|
||||
/// compromised internal account or a malicious admin.
|
||||
pub email_invite_rate_limiter: Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
|
||||
/// Per-target-email ceiling on `POST /api/auth/magic-link/send`. 5
|
||||
/// per hour keyed on the **normalised** target email. Exceeding
|
||||
/// the cap is silently absorbed: the handler still returns the
|
||||
/// uniform 200 anti-enumeration response, but no new mail is
|
||||
/// dispatched. Authenticated callers bypass this limit.
|
||||
pub magic_link_send_per_email_rate_limiter:
|
||||
Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
|
||||
/// Per-source-IP backstop on `POST /api/auth/magic-link/send`. 200
|
||||
/// per hour keyed on the trusted client IP (respects
|
||||
/// `OXICLOUD_TRUST_PROXY_CIDR`). Bounds the cost of a single
|
||||
/// attacker spreading 5/hr requests over a wide email list.
|
||||
/// Authenticated callers bypass this limit.
|
||||
pub magic_link_send_per_ip_rate_limiter:
|
||||
Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
|
||||
}
|
||||
|
||||
// All AppState construction is done via struct literal in build_app_state().
|
||||
|
||||
@@ -914,9 +914,17 @@ pub struct SendMagicLinkDto {
|
||||
/// the absence of the entire feature is visible from any other
|
||||
/// endpoint touching `/api/auth/magic-link/*`.
|
||||
///
|
||||
/// Per-target-email rate limit is scheduled for PR 12 — without it,
|
||||
/// the endpoint could be used as an email-bombing primitive against a
|
||||
/// known recipient address.
|
||||
/// PR 12 rate limits:
|
||||
/// - **Per-source-IP**, 200/hour — bounds the cost of one attacker
|
||||
/// spreading low per-email volumes over many target addresses.
|
||||
/// - **Per-target-email**, 5/hour, keyed on the normalised email —
|
||||
/// stops the endpoint from being an email-bombing primitive against
|
||||
/// a single known recipient.
|
||||
/// Both caps return the uniform 200 (never 429 to anonymous callers,
|
||||
/// otherwise the status itself becomes an enumeration oracle); the
|
||||
/// real reason is recorded in the audit channel.
|
||||
/// Authenticated callers (Authorization header or access cookie
|
||||
/// present) bypass both limits.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/magic-link/send",
|
||||
@@ -929,7 +937,7 @@ pub struct SendMagicLinkDto {
|
||||
)]
|
||||
pub async fn send_magic_link(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<SendMagicLinkDto>,
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return Err(AppError::new(
|
||||
@@ -939,6 +947,91 @@ pub async fn send_magic_link(
|
||||
));
|
||||
};
|
||||
|
||||
// Authentication signal — presence (not validity) of Bearer header
|
||||
// OR access cookie. We deliberately don't decode the JWT here: a
|
||||
// stale-cookie holder gets a 401 from any other endpoint they
|
||||
// touch, and the worst-case bypass of these anti-flood caps is a
|
||||
// narrow window where an attacker keeps a single expired cookie
|
||||
// alive. False-negatives (a logged-in user being rate-limited
|
||||
// resending to themselves) are the real cost we're avoiding.
|
||||
let headers = req.headers().clone();
|
||||
let is_authenticated = headers.contains_key(axum::http::header::AUTHORIZATION)
|
||||
|| crate::interfaces::api::cookie_auth::extract_cookie_value(
|
||||
&headers,
|
||||
crate::interfaces::api::cookie_auth::ACCESS_COOKIE,
|
||||
)
|
||||
.is_some();
|
||||
|
||||
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&req);
|
||||
|
||||
// Body parsing — manual because Request<Body> already consumed
|
||||
// any chance of a Json extractor. 4 KiB is generous for
|
||||
// `{ "email": "..." }`.
|
||||
let body_bytes = axum::body::to_bytes(req.into_body(), 4 * 1024)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Request body too large or unreadable",
|
||||
"InvalidInput",
|
||||
)
|
||||
})?;
|
||||
let body: SendMagicLinkDto = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid JSON body: {e}"),
|
||||
"InvalidInput",
|
||||
)
|
||||
})?;
|
||||
|
||||
let uniform_ok = || {
|
||||
let payload = serde_json::json!({
|
||||
"message": "If an account exists for that email, a sign-in link will be sent.",
|
||||
});
|
||||
(StatusCode::OK, Json(payload)).into_response()
|
||||
};
|
||||
|
||||
if !is_authenticated {
|
||||
// Per-IP backstop fires first — covers the case where an
|
||||
// attacker iterates many distinct emails to spread the
|
||||
// per-email budget thin.
|
||||
if state
|
||||
.magic_link_send_per_ip_rate_limiter
|
||||
.check_and_increment(&client_ip)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_ip",
|
||||
ip = %client_ip,
|
||||
"Per-IP rate limit exceeded on /api/auth/magic-link/send"
|
||||
);
|
||||
return Ok(uniform_ok());
|
||||
}
|
||||
|
||||
// Per-target-email cap, keyed on the normalised form so
|
||||
// casing/IDN-host tricks don't multiply the budget. Malformed
|
||||
// addresses skip this check and fall through to the service,
|
||||
// which records its own audit entry under reason="malformed_email".
|
||||
if let Ok(normalised) =
|
||||
crate::domain::services::email_normalize::normalize_email(&body.email)
|
||||
&& state
|
||||
.magic_link_send_per_email_rate_limiter
|
||||
.check_and_increment(&normalised)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_email",
|
||||
ip = %client_ip,
|
||||
"Per-target-email rate limit exceeded on /api/auth/magic-link/send"
|
||||
);
|
||||
return Ok(uniform_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// The service swallows every operational outcome and logs the truth
|
||||
// via the audit channel; we surface only an internal error (DB down,
|
||||
// etc.). Anti-enumeration means we always return the same body.
|
||||
@@ -947,8 +1040,5 @@ pub async fn send_magic_link(
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"message": "If an account exists for that email, a sign-in link will be sent.",
|
||||
});
|
||||
Ok((StatusCode::OK, Json(payload)).into_response())
|
||||
Ok(uniform_ok())
|
||||
}
|
||||
|
||||
@@ -116,6 +116,27 @@ pub async fn create_grant(
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
// PR 12 — per-sharer ceiling: 50 email-invitations / hour
|
||||
// per caller. Hitting the cap returns 429 because the
|
||||
// caller is authenticated and rate-limit visibility leaks
|
||||
// nothing they don't already know about their own
|
||||
// behaviour.
|
||||
if state
|
||||
.email_invite_rate_limiter
|
||||
.check_and_increment(&caller_id.to_string())
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "grants.email_invite",
|
||||
reason = "rate_limited",
|
||||
caller_id = %caller_id,
|
||||
"Per-sharer email-invite rate limit exceeded"
|
||||
);
|
||||
return crate::interfaces::middleware::rate_limit::too_many_requests(
|
||||
state.email_invite_rate_limiter.retry_after(),
|
||||
);
|
||||
}
|
||||
match invite_svc.resolve_or_create_recipient(&email).await {
|
||||
Ok(user) => (Subject::User(user.id()), Some(user)),
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
|
||||
@@ -94,7 +94,11 @@ pub fn extract_client_ip<B>(req: &Request<B>) -> String {
|
||||
}
|
||||
|
||||
/// Build a rate-limit response with the standard `Retry-After` header.
|
||||
fn too_many_requests(retry_after: u64) -> Response {
|
||||
///
|
||||
/// Public so handlers that do their own (non-middleware) rate checks —
|
||||
/// e.g. the email-invite branch of `POST /api/grants`, where the limit
|
||||
/// only applies to one subject variant — can return the same shape.
|
||||
pub fn too_many_requests(retry_after: u64) -> Response {
|
||||
let body = serde_json::json!({
|
||||
"error": "Too many requests",
|
||||
"retry_after_secs": retry_after,
|
||||
|
||||
@@ -354,10 +354,88 @@ Authorization: Bearer {{alice_token}}
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 16 — Rate-limit caps (PR 12). Test-only thresholds come
|
||||
# from tests/common/server.env:
|
||||
# OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3
|
||||
# OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2
|
||||
# Alice already burned 2 invite slots earlier (bob's
|
||||
# folder + ext-share-2) and 1 send slot in Step 15a.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
# 16a — Alice's 3rd email-invite (3/3) succeeds — right at the
|
||||
# cap. Fresh email so resolve_or_create_recipient mints a
|
||||
# new external user we'll clean up below.
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "email", "email": "ratelimit-test-1@externalcompany.com" },
|
||||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
rl_user_1_id: jsonpath "$[0].subject.id"
|
||||
|
||||
# 16b — 4th invite (4/3) is rejected with 429 + Retry-After. The
|
||||
# cap is visible because Alice is authenticated and her own
|
||||
# rate-limit state leaks nothing about other accounts.
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "email", "email": "ratelimit-test-2@externalcompany.com" },
|
||||
"resource": { "type": "folder", "id": "{{ext_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 429
|
||||
[Asserts]
|
||||
header "retry-after" exists
|
||||
jsonpath "$.retry_after_secs" >= 1
|
||||
|
||||
# 16c — Anonymous /magic-link/send to bob (2/2 — at cap). Returns
|
||||
# the same uniform 200 a successful issuance would; the
|
||||
# audit log distinguishes the two.
|
||||
POST {{base_url}}/api/auth/magic-link/send
|
||||
Content-Type: application/json
|
||||
{ "email": "bob@externalcompany.com" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.message" contains "sign-in link"
|
||||
|
||||
# 16d — 3rd anonymous send to bob (3/2 — over cap). Anti-enumeration:
|
||||
# must NOT return 429, must NOT change the response shape.
|
||||
POST {{base_url}}/api/auth/magic-link/send
|
||||
Content-Type: application/json
|
||||
{ "email": "bob@externalcompany.com" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.message" contains "sign-in link"
|
||||
|
||||
# 16e — Authenticated callers bypass both anti-flood caps. Alice
|
||||
# resends to bob with her Bearer token; the per-email and
|
||||
# per-IP counters are not consulted (a logged-in user
|
||||
# resending should never be throttled). Still returns 200.
|
||||
POST {{base_url}}/api/auth/magic-link/send
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "email": "bob@externalcompany.com" }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.message" contains "sign-in link"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Cleanup. Alice trashes the two test folders and
|
||||
# deletes bob via the admin API so the suite's
|
||||
# storage-check sweep at run.sh end sees a clean DB.
|
||||
# deletes bob + the two rate-limit-test externals via
|
||||
# the admin API so the suite's storage-check sweep at
|
||||
# run.sh end sees a clean DB.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{ext_folder_id_2}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
@@ -373,3 +451,8 @@ DELETE {{base_url}}/api/admin/users/{{bob_user_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP *
|
||||
|
||||
DELETE {{base_url}}/api/admin/users/{{rl_user_1_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP *
|
||||
|
||||
@@ -34,3 +34,10 @@ OXICLOUD_SMTP_PORT=25
|
||||
OXICLOUD_SMTP_FROM='OxiCloud Tests <test@oxicloud.local>'
|
||||
OXICLOUD_SMTP_TLS=none
|
||||
OXICLOUD_ALLOW_EXTERNAL_USERS=true
|
||||
|
||||
# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can
|
||||
# exercise the cap behaviour with a small, deterministic request count.
|
||||
# Production defaults are 50 / 5 / 200 respectively (see example.env).
|
||||
OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3
|
||||
OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2
|
||||
OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50
|
||||
|
||||
Reference in New Issue
Block a user