feat(magic-links): add rate limiting + archirecture documentation

This commit is contained in:
Edouard Vanbelle
2026-06-02 14:23:31 +02:00
parent 64d081ad0b
commit 21c06da700
11 changed files with 530 additions and 17 deletions
+1
View File
@@ -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" },
+200
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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"));
+52
View File
@@ -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().
+98 -8
View File
@@ -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(),
+5 -1
View File
@@ -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,
+85 -2
View File
@@ -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 *
+7
View File
@@ -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