Skip to content

fix: validate X-Api-Key against configured allowlist#130

Closed
dobby-coder[bot] wants to merge 2 commits intomainfrom
fix/api-key-allowlist-validation
Closed

fix: validate X-Api-Key against configured allowlist#130
dobby-coder[bot] wants to merge 2 commits intomainfrom
fix/api-key-allowlist-validation

Conversation

@dobby-coder
Copy link
Copy Markdown
Contributor

@dobby-coder dobby-coder Bot commented May 1, 2026

Summary

Closes #123.

The ApiKeyPresent request guard only checked presence of the X-Api-Key header — any value (X-Api-Key: x) admitted the caller to the higher-quota tier (100 GB per upload + 100 GB rolling, vs. the default 5 GB). The value was never forwarded to PKG either, so the comment "PKG handles validation" was wrong.

What changed

  • New api_keys config section: a list of { tenant, hash } entries where hash is hex sha256 of the raw key value. Raw keys never appear on disk.
  • New validate_api_key(header_value, configured) -> Option<String> helper in src/config.rs that hashes the incoming value and matches it against the allowlist with a constant-time compare per candidate. Returns the tenant id on match, None on miss / missing / empty.
  • ApiKeyPresent(bool) is replaced by ApiKey(Option<String>). FileState.is_api_key: bool becomes FileState.api_key_tenant: Option<String>. Limit selection (PER_UPLOAD_LIMIT vs. API_KEY_PER_UPLOAD_LIMIT, same for rolling) keys off Option::is_some().
  • Rolling-window accounting now keys on api-key:<tenant> for API-tier callers (per-tenant, not per sender email), addressing the "rolling window is per sender email — a known, weaker tracker" point in the issue. /usage?email=… returns the per-tenant total when X-Api-Key is validated, otherwise the per-email total as before.
  • Empty allowlist (or the section omitted) disables the API-key tier — every request is treated as default tier, including ones that send an X-Api-Key header.

Tests

src/config.rs ships 8 new unit tests on validate_api_key:

  • unset header → None
  • empty header → None
  • arbitrary value ("anything", "x") → None (pins the new behaviour: presence-only no longer grants access)
  • empty allowlist → None for any value
  • correct value → Some(tenant)
  • hash field case-insensitive (uppercase hex still matches)
  • header value case-sensitive (uppercased value does NOT match)
  • multi-entry allowlist matches the right entry

Full test suite: cargo test38 passed; 0 failed. cargo fmt --all -- --check and cargo check are both clean on the touched files.

Reviewer quickstart

git fetch origin && git checkout fix/api-key-allowlist-validation && cargo test

To exercise the guard end-to-end against a running instance, generate a hash with printf 'YOUR-KEY' | sha256sum, drop it into conf/config.dev.toml under [[api_keys]], restart the container (bind-mounted config does NOT pick up changes without a restart — see repos/cryptify.md), and send a request with X-Api-Key: YOUR-KEY.

Notes / follow-ups

  • This PR keeps the constants API_KEY_PER_UPLOAD_LIMIT = 100 GB and API_KEY_ROLLING_LIMIT = 100 GB exactly as before. If those should change at the same time, that's a separate small PR.
  • The frontend (postguard-website) and extensions do not need changes — they already send X-Api-Key when configured. Today they would silently get the default tier if the key is not in the allowlist; that is the intended behaviour.

🤖 Generated with Claude Code

The previous request guard only checked that an `X-Api-Key` header was
present and admitted any non-empty value to the higher-quota tier
(100 GB/upload, 100 GB rolling window vs. the default 5 GB). An
unauthenticated client could defeat the storage / bandwidth abuse
controls by sending an arbitrary header value.

Validate the header value against a configured allowlist of sha256
hashes, identify the caller by tenant id when matched, and use that
tenant id as the rolling-window accounting key
(`api-key:<tenant>`). When no allowlist is configured (or the value
does not match), the caller is treated as the default tier — including
requests that send an `X-Api-Key` header.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dobby-coder dobby-coder Bot requested a review from rubenhensen May 1, 2026 08:12
@dobby-coder dobby-coder Bot marked this pull request as ready for review May 1, 2026 08:17
@rubenhensen
Copy link
Copy Markdown
Contributor

solve the conflicts

@dobby-coder
Copy link
Copy Markdown
Contributor Author

dobby-coder Bot commented May 6, 2026

The bot is on it! Resolving conflicts on PR #130 right away — Dobby will rebase against main and check the build before pushing!

…-validation

# Conflicts:
#	src/main.rs
#	src/store.rs
@dobby-coder
Copy link
Copy Markdown
Contributor Author

dobby-coder Bot commented May 6, 2026

Conflicts resolved against main (e1dc01a).

Two conflicts, both in fields added to FileState:

  • src/store.rs — branch added api_key_tenant: Option<String> (replacing is_api_key: bool); main added notify_recipients: bool (PR feat(upload): add notifyRecipients toggle on /fileupload/init #135). Kept both fields with their respective doc comments. The branch's is_api_key rename to api_key_tenant stands — main's is_api_key: bool was the old presence-only signal that this PR is replacing, so adopting it would have undone the fix.
  • src/main.rs — same struct, in upload_init. Kept notify_recipients: request.notify_recipients and api_key_tenant: api_key.0 side-by-side.

No code outside the two conflict sites needed to change — main's email.rs:218 if state.notify_recipients reference and the branch's state.api_key_tenant.is_some() checks in main.rs (per-upload + rolling limit selection) all resolve correctly against the merged struct.

Verification on merged tree:

  • cargo build --release — clean
  • cargo test --release — 38 passed; 0 failed
  • cargo fmt --all -- --check — clean
  • cargo clippy --all-targets --release -- -D warnings — clean
  • git diff origin/main --stat — 4 files / +228 / −21 (not a no-op; API-key validation intact)

PR is back to mergeable. CC @rubenhensen.

@rubenhensen
Copy link
Copy Markdown
Contributor

Closing in favor of the redesign discussed in #123: cryptify will validate the API key against pg-pkg via a new /v2/api-key/validate endpoint instead of maintaining a parallel hashed allowlist in cryptify's config. Single source of truth (business_api_keys), no drift on revoke/rotate. Replacement work in flight.

@rubenhensen rubenhensen closed this May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security: X-Api-Key presence-only check allows quota bypass (5GB → 100GB)

1 participant