Skip to content

fix(security): address 6 server vulnerabilities (H-1, H-2, H-3, H-4, M-1, M-2)#57

Open
AbianS wants to merge 1 commit into
mainfrom
fix/security-audit-h1-h2-h3-h4-m1-m2
Open

fix(security): address 6 server vulnerabilities (H-1, H-2, H-3, H-4, M-1, M-2)#57
AbianS wants to merge 1 commit into
mainfrom
fix/security-audit-h1-h2-h3-h4-m1-m2

Conversation

@AbianS
Copy link
Copy Markdown
Owner

@AbianS AbianS commented May 11, 2026

Summary

Security audit of apps/server/src/ found 4 HIGH and 4 MEDIUM issues. This PR fixes 6 of them using TDD (failing test written before each fix).

  • H-1 (timing oracle)User::run_dummy_password_verify() runs Argon2 against a dummy hash when login email doesn't exist, equalizing response time with the wrong-password path
  • H-2/M-3 (password length DoS) — register enforces 8–1024 char limits; login rejects >1024 before any DB or Argon2 work
  • H-3 (internal error leakage)AppError::Database and AppError::Internal now log full detail server-side and return a generic "An internal error occurred" to clients
  • H-4 (SSRF via webhook URLs)validate_config blocks loopback, RFC1918, link-local (169.254.x.x), and dangerous hostnames (localhost, *.local, *.internal)
  • M-1 (wrong status on oversized body) — ingest endpoint collects Payload manually with a 100MB cap, returning AppError::PayloadTooLarge (413 JSON) instead of actix-web's default plain-text response
  • M-2 (session fixation)session.clear() + session.renew() called before set_user_session on both login and register to regenerate the session token

Test plan

  • Each fix has a failing test written first (red→green)
  • New test files: tests/unit/error_test.rs, tests/unit/user_test.rs
  • New tests added to: tests/integration/auth_test.rs, tests/integration/ingest_test.rs, src/services/notification/webhook.rs (inline)
  • cargo test --no-default-features --features postgres — 135 unit + 97 integration, 0 failures

Summary by CodeRabbit

  • Bug Fixes

    • Improved error message handling to prevent exposure of internal system details in API responses.
  • New Features

    • Added password length requirements (8–1024 characters) for authentication.
    • Implemented 100MB payload size limit for data ingestion requests.
    • Added validation to prevent webhook URLs from targeting private or internal network addresses.

Review Change Stack

H-1 (timing oracle): run dummy Argon2 verify when login email not found,
equalizing response time between missing-user and wrong-password paths.

H-2/M-3 (password length): enforce 8–1024 char limits on register;
reject passwords >1024 on login before DB/Argon2 work (CPU DoS prevention).

H-3 (error leakage): sanitize Database and Internal errors in error_response
— log full detail server-side, return generic message to client.

H-4 (SSRF): block private/internal URLs in webhook validate_config —
loopback, RFC1918, link-local (169.254.x.x), localhost, *.local, *.internal.

M-1 (413 format): collect ingest Payload manually with 100MB cap so
oversized bodies return AppError::PayloadTooLarge (413 JSON) not actix default.

M-2 (session fixation): call session.clear() + session.renew() before
set_user_session on login and register to regenerate the session token.

Each fix is covered by a failing test written first (TDD red→green).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 11, 2026

Walkthrough

This PR implements multi-layered security hardening across error handling, password verification, session management, request validation, and webhook configuration. It prevents information leakage in error responses, eliminates timing-oracle vulnerabilities in password checks, prevents session fixation attacks, enforces payload size limits, and blocks SSRF attempts to internal resources.

Changes

Security Hardening

Layer / File(s) Summary
Error Response Masking
apps/server/src/error.rs, apps/server/tests/unit/error_test.rs, apps/server/tests/unit/mod.rs
AppError::error_response() masks Database and Internal error types as "InternalError", logs full details server-side via log::error!(), and returns a generic "An internal error occurred" message to clients. Validation errors preserve their provided message.
Timing Oracle Prevention
apps/server/src/models/user.rs, apps/server/tests/unit/user_test.rs
Module-level constant DUMMY_PASSWORD_HASH provides a pre-computed Argon2id hash. User::run_dummy_password_verify() parses the hash, performs Argon2 verification to equalize execution time, then unconditionally returns false to prevent timing leakage when user emails are not found.
Auth Handler Password & Session Hardening
apps/server/src/routes/auth.rs, apps/server/tests/integration/auth_test.rs
register enforces password length (8–1024 chars), clears existing session, and renews token before setting user session. login rejects passwords > 1024 chars upfront, runs dummy verification for missing users, and clears/renews session after successful authentication. Integration tests verify password boundary enforcement and session fixation prevention via cookie persistence checks.
Payload Size Limiting
apps/server/src/routes/ingest.rs, apps/server/tests/integration/ingest_test.rs
Ingest handler accepts streaming web::Payload, buffers into BytesMut with MAX_INGEST_PAYLOAD_BYTES limit (100MB), and rejects oversized requests with PayloadTooLarge error before decompression. Integration test validates 300KB payload returns HTTP 413 with JSON error structure.
Webhook SSRF Filtering
apps/server/src/services/notification/webhook.rs
validate_config parses webhook URL and checks the host against blocked patterns: IPv4 loopback/RFC1918/0.0.0.0/169.254, IPv6 loopback/unique-local/link-local/IPv4-mapped, and hostnames matching localhost, *.local, *.internal. Helper functions classify blocked addresses. Unit tests verify rejection of internal endpoints and acceptance of public URLs.

Sequence Diagram

sequenceDiagram
  participant Client as Client/Attacker
  participant AuthHandler as Auth Handler
  participant DB as Database
  participant User as User Model
  participant Session as Session Manager
  
  rect rgba(255, 0, 0, 0.5)
  Note over Client,Session: Timing Oracle Attack Prevention
  Client->>AuthHandler: login(non-existent@email.com, password)
  AuthHandler->>DB: find user by email
  DB-->>AuthHandler: not found (null)
  AuthHandler->>User: run_dummy_password_verify(password)
  Note over User: Execute Argon2 hash<br/>return false regardless
  User-->>AuthHandler: false
  AuthHandler-->>Client: Unauthorized (same timing as found user)
  end
  
  rect rgba(0, 255, 0, 0.5)
  Note over Client,Session: Session Fixation Prevention
  Client->>AuthHandler: login(valid@email.com, password)<br/>with attacker-planted session cookie
  AuthHandler->>Session: clear existing session data
  AuthHandler->>Session: renew session token
  AuthHandler->>Session: set authenticated user session
  Session-->>AuthHandler: new session established
  AuthHandler-->>Client: authenticated (old cookie invalid)
  Note over Client: Attacker-planted data cleared
  end
  
  rect rgba(0, 0, 255, 0.5)
  Note over Client,Session: Error Masking
  Client->>AuthHandler: any request → internal DB error occurs
  AuthHandler->>AuthHandler: log full error server-side
  AuthHandler-->>Client: { error_type: "InternalError",<br/>message: "An internal error occurred" }
  Note over Client: Internal details hidden from client
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • AbianS/rustrak#52: The ingest handler change addresses the concern about the 256KB default Bytes limit by moving payload collection into the handler with a configurable 100MB MAX_INGEST_PAYLOAD_BYTES constant.

Possibly related PRs

  • AbianS/rustrak#19: Both PRs modify apps/server/src/services/notification/webhook.rs — this PR adds host-based SSRF filtering/validation to the webhook notifier logic introduced in PR #19.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main objective: addressing 6 security vulnerabilities identified in the audit with specific vulnerability codes.
Docstring Coverage ✅ Passed Docstring coverage is 97.14% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/security-audit-h1-h2-h3-h4-m1-m2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/server/src/routes/auth.rs (1)

162-170: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Residual timing oracle on is_active = false users.

H-1 closes the user-not-found path by running Argon2 against a dummy hash. The is_active == false branch still returns Unauthorized without running Argon2 (line 164), so its response time is materially shorter than both the not-found path and the wrong-password path. That lets an unauthenticated attacker still enumerate accounts whose state is "exists but disabled".

Cheapest fix: also call User::run_dummy_password_verify (or just user.verify_password) on this branch before returning, so all three failure modes share the same Argon2 cost.

🛡️ Suggested fix
     // Check if user is active
     if !user.is_active {
+        // Run verification anyway to equalize timing with the other failure paths
+        let _ = user.verify_password(&req.password);
         return Err(AppError::Unauthorized("Account is disabled".to_string()));
     }

Note: also consider returning a single uniform "Invalid credentials" message instead of "Account is disabled", since the distinct message is itself an enumeration oracle independent of timing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/routes/auth.rs` around lines 162 - 170, When the user is
inactive (!user.is_active) run the same expensive password verification as other
failure paths to avoid a timing oracle: call User::run_dummy_password_verify()
(or user.verify_password(&req.password) and ignore/handle its Result) before
returning the AppError::Unauthorized; ensure the call executes even if it fails,
then return the same Unauthorized response (consider using the same "Invalid
credentials" text to avoid message-based enumeration).
apps/server/src/routes/ingest.rs (1)

33-64: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reordering opportunity: read up to 100MB before the rate-limit check.

The fail-fast intent in the comment at line 51 is undermined by the new collection step on lines 36–49: the handler now eagerly buffers up to MAX_INGEST_PAYLOAD_BYTES (100 MB) into memory before RateLimitService::check_quota runs. An already rate-limited project can therefore still force the server to allocate and copy 100 MB per request — a meaningful DoS amplification compared to the old Bytes extractor path (which had a much smaller default limit) and a regression of the "fail fast" promise.

check_quota only needs pool and auth.project, both of which are available pre-body. Moving it ahead of the body collection restores fail-fast and bounds memory for throttled callers.

🛡️ Proposed reordering
     mut payload: web::Payload,
 ) -> AppResult<HttpResponse> {
-    // Collect body with explicit size limit so we control the error format (413 JSON)
-    let body: Bytes = {
-        let mut buf = BytesMut::new();
-        while let Some(chunk) = payload.next().await {
-            let chunk =
-                chunk.map_err(|e| AppError::Internal(format!("Payload read error: {e}")))?;
-            if buf.len() + chunk.len() > MAX_INGEST_PAYLOAD_BYTES {
-                return Err(AppError::PayloadTooLarge(
-                    "Payload exceeds maximum size".to_string(),
-                ));
-            }
-            buf.extend_from_slice(&chunk);
-        }
-        buf.freeze()
-    };
-
     // 0. Check rate limits (fail fast before processing)
     if let Some(exceeded) = RateLimitService::check_quota(pool.get_ref(), &auth.project).await? {
         log::warn!(
             "Rate limit exceeded for project {}: retry_after={}s",
             auth.project.id,
             exceeded.retry_after
         );
         return Ok(HttpResponse::TooManyRequests()
             .insert_header(("Retry-After", exceeded.retry_after.to_string()))
             .json(serde_json::json!({
                 "error": "rate_limit_exceeded",
                 "retry_after": exceeded.retry_after
             })));
     }
+
+    // Collect body with explicit size limit so we control the error format (413 JSON)
+    let body: Bytes = {
+        let mut buf = BytesMut::new();
+        while let Some(chunk) = payload.next().await {
+            let chunk =
+                chunk.map_err(|e| AppError::Internal(format!("Payload read error: {e}")))?;
+            if buf.len() + chunk.len() > MAX_INGEST_PAYLOAD_BYTES {
+                return Err(AppError::PayloadTooLarge(
+                    "Payload exceeds maximum size".to_string(),
+                ));
+            }
+            buf.extend_from_slice(&chunk);
+        }
+        buf.freeze()
+    };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/routes/ingest.rs` around lines 33 - 64, The handler currently
buffers the request body up to MAX_INGEST_PAYLOAD_BYTES before performing the
rate-limit check, which defeats the "fail fast" intent; move the
RateLimitService::check_quota call (using pool.get_ref() and &auth.project) to
run before the body collection loop so rate-limited projects return
TooManyRequests without allocating/reading the body, then proceed to read the
payload into buf/Bytes only after the quota check succeeds.
🧹 Nitpick comments (8)
apps/server/tests/unit/error_test.rs (1)

36-51: 💤 Low value

Optional: also assert the generic message is present on Internal.

The DB test asserts the response contains "internal", but test_internal_error_does_not_leak_internal_details only checks absence of leak substrings. A trivially-passing implementation that returns an empty body would still satisfy this test. Mirror the positive check from the DB test for symmetry.

♻️ Suggested addition
     assert!(
         !body.contains("unique constraint"),
         "DB constraint name leaked: {body}"
     );
+    assert!(
+        body.to_lowercase().contains("internal"),
+        "Expected generic internal error message, got: {body}"
+    );
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/tests/unit/error_test.rs` around lines 36 - 51, Add a positive
assertion in test_internal_error_does_not_leak_internal_details to ensure the
generic error text is returned (not just absence of DB details): after obtaining
body via body_string(app_err.error_response()), assert that body contains the
same generic/internal message used elsewhere (e.g., the string checked in the DB
test, such as "internal" or the standardized generic phrase) so the test
verifies presence of the public message when AppError::Internal is used.
apps/server/src/routes/ingest.rs (3)

18-49: ⚡ Quick win

Boundary check is correct; consider a tiny capacity hint and a head-of-stream Content-Length reject.

The buf.len() + chunk.len() > MAX_INGEST_PAYLOAD_BYTES guard correctly rejects before extending, so the buffer never grows past the cap — good. Two cheap optional improvements:

  1. Read Content-Length from req.headers() first and short-circuit with PayloadTooLarge before consuming any bytes. This avoids streaming and discarding ~100 MB from a misbehaving/malicious client just to reach the same verdict.
  2. Pre-size BytesMut::with_capacity(...) from a clamped Content-Length when present, to reduce reallocations on the hot path.
♻️ Sketch
if let Some(len) = req
    .headers()
    .get(actix_web::http::header::CONTENT_LENGTH)
    .and_then(|v| v.to_str().ok())
    .and_then(|s| s.parse::<usize>().ok())
{
    if len > MAX_INGEST_PAYLOAD_BYTES {
        return Err(AppError::PayloadTooLarge(
            "Payload exceeds maximum size".to_string(),
        ));
    }
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/routes/ingest.rs` around lines 18 - 49, The ingest_envelope
handler currently streams the payload and checks buf.len() + chunk.len() against
MAX_INGEST_PAYLOAD_BYTES, but you should early-reject by checking the request
Content-Length and pre-size the buffer to avoid streaming large unwanted bodies
and repeated reallocations: read req.headers() for
actix_web::http::header::CONTENT_LENGTH, parse it to usize, return
AppError::PayloadTooLarge immediately if it exceeds MAX_INGEST_PAYLOAD_BYTES,
and when Content-Length is present and <= MAX_INGEST_PAYLOAD_BYTES initialize
the accumulator with BytesMut::with_capacity(clamped_len) (falling back to a
small default otherwise) before the payload loop in ingest_envelope.

17-18: Operational note: 100 MB × concurrency is the real memory ceiling.

MAX_INGEST_PAYLOAD_BYTES = 100 MB bounds a single request, but the ingest hot path is fully in-memory (Bytesdecompress_bodyEnvelopeParser), so peak server memory scales with concurrent envelope uploads. Worth pairing this constant with one (or several) of:

  • An app-level concurrency limiter / semaphore around ingest_envelope (e.g., tokio::sync::Semaphore) to cap simultaneous large bodies.
  • Documenting the expected worker_threads × max body × decompress amplification envelope and surfacing it via metrics (in-flight ingest bytes, rejection counts for PayloadTooLarge).
  • Considering a lower per-request cap unless 100 MB envelopes are a real customer need — Sentry itself caps envelopes well below that.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/routes/ingest.rs` around lines 17 - 18,
MAX_INGEST_PAYLOAD_BYTES only limits a single request but peak memory scales
with concurrent uploads; wrap the ingest hot path (ingest_envelope →
decompress_body → EnvelopeParser) with an application-level concurrency limiter
(e.g., a tokio::sync::Semaphore or similar) to cap simultaneous large-body
processing, acquire a permit at the start of ingest_envelope and release on
completion/failure, and add basic metrics (in_flight_ingest_bytes,
ingest_rejections_payload_too_large) so we can observe and reject when capacity
is exceeded; alternatively reduce MAX_INGEST_PAYLOAD_BYTES if 100 MB per-request
is not required.

146-156: 💤 Low value

Legacy /store/ still uses default-limit Bytes extractor — inconsistent but probably fine.

The deprecated /store/ handler still takes _body: Bytes, which uses Actix's default extractor size limit (different from the new 100 MB cap on /envelope/). Since this endpoint immediately returns a Validation error and never inspects the body, the practical impact is just that a large /store/ body may now produce a 400-ish "payload too large" coming from the extractor itself, rather than the friendly deprecation message. Worth either dropping the Bytes extractor entirely (cheaper) or aligning with the new manual reader.

♻️ Suggested simplification
 pub async fn ingest_store(
     _pool: web::Data<DbPool>,
     _config: web::Data<Config>,
     _req: HttpRequest,
     _auth: SentryAuth,
-    _body: Bytes,
 ) -> AppResult<HttpResponse> {
     Err(AppError::Validation(
         "The /store/ endpoint is deprecated. Please use /envelope/ instead.".to_string(),
     ))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/routes/ingest.rs` around lines 146 - 156, The ingest_store
handler currently accepts a _body: Bytes extractor which invokes Actix's default
payload size limit and can yield a "payload too large" before returning the
deprecation Validation error; remove the _body: Bytes parameter from the
ingest_store function signature (keeping the function name ingest_store and the
AppError::Validation return) so the handler no longer triggers the extractor,
and update any route registration that references this handler signature if
necessary; alternatively, if you prefer to preserve a body argument, replace
Bytes with a non-extracting type (e.g., remove or accept HttpRequest only) or
read the payload manually with the same 100MB limit used by the /envelope/ path
to keep behavior consistent.
apps/server/tests/integration/auth_test.rs (1)

1150-1245: ⚡ Quick win

Session fixation test verifies clear() but not renew() cookie rotation.

The test confirms attacker-planted session entries are not readable after login — that exercises session.clear(). However, it does not assert that the session cookie value itself changed, which is the property session.renew() enforces. The login endpoint calls both session.clear() and session.renew() (lines 176–177 in apps/server/src/routes/auth.rs), and with CookieSessionStore the cookie value rotation is the actual identifier change that prevents session fixation.

Adding assert_ne!(new_session_cookie, session_cookie) provides complete M-2 coverage for session fixation defense (both data clearing and identifier rotation).

♻️ Proposed addition
     let new_session_cookie = login_resp
         .headers()
         .get("set-cookie")
         .unwrap()
         .to_str()
         .unwrap()
         .split(';')
         .next()
         .unwrap()
         .to_string();
+
+    // session.renew() must change the cookie value so the pre-login identifier
+    // cannot be reused post-auth (true session-fixation defense).
+    assert_ne!(
+        new_session_cookie, session_cookie,
+        "Session fixation: session identifier must change across login"
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/tests/integration/auth_test.rs` around lines 1150 - 1245, The
test test_login_clears_pre_existing_session_data currently verifies
session.clear() but misses checking cookie rotation from session.renew(); after
you extract new_session_cookie (the response from the login call) add an
assertion asserting the cookie changed (assert_ne!(new_session_cookie,
session_cookie)) to ensure the session identifier was rotated when using
CookieSessionStore; place this assertion immediately after obtaining
new_session_cookie and before the subsequent request that reads the marker.
apps/server/src/services/notification/webhook.rs (2)

305-324: ⚡ Quick win

Add a test for IPv4-mapped IPv6 (::ffff:127.0.0.1).

The to_ipv4_mapped() branch in is_private_ipv6 (line 229) is currently uncovered. A single assertion would lock in that behavior and guard against regressions, since this is exactly the kind of edge case attackers probe for.

🧪 Suggested addition
     #[test]
     fn test_rejects_ipv6_loopback() {
         let notifier = WebhookNotifier::new();
         assert!(notifier
             .validate_config(&make_webhook_config("http://[::1]/hook"))
             .is_err());
+        // IPv4-mapped IPv6 pointing at loopback must also be rejected.
+        assert!(notifier
+            .validate_config(&make_webhook_config("http://[::ffff:127.0.0.1]/hook"))
+            .is_err());
+        // Unique-local and link-local IPv6 should be rejected.
+        assert!(notifier
+            .validate_config(&make_webhook_config("http://[fc00::1]/hook"))
+            .is_err());
+        assert!(notifier
+            .validate_config(&make_webhook_config("http://[fe80::1]/hook"))
+            .is_err());
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/services/notification/webhook.rs` around lines 305 - 324, Add
a unit test that covers the IPv4-mapped IPv6 case so the to_ipv4_mapped() branch
in is_private_ipv6 is exercised: create a test function (similar to
test_rejects_ipv6_loopback) that constructs a WebhookNotifier and calls
validate_config with a webhook URL using the IPv4-mapped IPv6 loopback (e.g.
"http://[::ffff:127.0.0.1]/hook") and assert it returns Err; reference
WebhookNotifier, validate_config, and is_private_ipv6/to_ipv4_mapped in the test
name or comment to make the intent clear.

185-233: ⚡ Quick win

Consider tightening the SSRF block list with a few more well-known ranges.

For defense in depth the IPv4/IPv6 classifiers miss a handful of ranges that are commonly abused or non-routable:

  • IPv4: 100.64.0.0/10 (CGNAT / shared address space), 255.255.255.255 (limited broadcast), 224.0.0.0/4 (multicast).
  • IPv6: :: (unspecified — can resolve to local services on some stacks), ff00::/8 (multicast), 64:ff9b::/96 (well-known NAT64), and the deprecated IPv4-compatible form ::a.b.c.d (note to_ipv4_mapped only catches ::ffff:a.b.c.d, not the IPv4-compatible variant which would need to_ipv4() instead).

None of these are exploitable by themselves in most deployments, but they're cheap to add and round out the policy. Alternatively, you can leverage Ipv4Addr::is_loopback/is_private/is_link_local/is_broadcast/is_multicast/is_unspecified and Ipv6Addr::is_loopback/is_unspecified/is_multicast from std to reduce hand-rolled bitmasks.

♻️ Sketch of an std-based simplification
 fn is_private_ipv4(ip: std::net::Ipv4Addr) -> bool {
-    let octets = ip.octets();
-    if octets[0] == 127 { return true; }
-    if octets[0] == 0 { return true; }
-    if octets[0] == 10 { return true; }
-    if octets[0] == 172 && (16..=31).contains(&octets[1]) { return true; }
-    if octets[0] == 192 && octets[1] == 168 { return true; }
-    if octets[0] == 169 && octets[1] == 254 { return true; }
-    false
+    let o = ip.octets();
+    ip.is_loopback()
+        || ip.is_private()
+        || ip.is_link_local()
+        || ip.is_broadcast()
+        || ip.is_multicast()
+        || ip.is_unspecified()
+        // CGNAT / shared address space (RFC 6598)
+        || (o[0] == 100 && (64..=127).contains(&o[1]))
 }

Ipv6Addr similarly exposes is_loopback, is_unspecified, and is_multicast on stable. The unique-local / link-local checks remain manual on stable Rust.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/services/notification/webhook.rs` around lines 185 - 233, The
SSRF IP classifiers in is_private_ipv4 and is_private_ipv6 miss several
non-routable/misused ranges; update is_private_ipv4 to also treat 100.64.0.0/10,
255.255.255.255 (limited broadcast), and 224.0.0.0/4 (multicast) as
private/non-routable, and update is_private_ipv6 to treat :: (unspecified),
ff00::/8 (multicast), and 64:ff9b::/96 (NAT64) as private; additionally detect
IPv4-compatible IPv6 (::a.b.c.d) by using ip.to_ipv4() (in addition to
to_ipv4_mapped()) and pass that IPv4 into is_private_ipv4; alternatively
consider replacing some hand-rolled checks with std methods like
Ipv4Addr::is_loopback/is_private/is_link_local/is_broadcast/is_multicast/is_unspecified
and Ipv6Addr::is_loopback/is_unspecified/is_multicast where appropriate to
simplify is_private_ipv4 and is_private_ipv6.
apps/server/tests/integration/ingest_test.rs (1)

602-603: 💤 Low value

Avoid expect(&format!(...)); prefer unwrap_or_else with a panic.

.expect(&format!(...)) allocates the message unconditionally on every call and is flagged by clippy's expect_fmt_panic_string lint. In test code this is harmless but trivially fixable.

♻️ Suggested change
-    let json: Value = serde_json::from_str(body_str)
-        .expect(&format!("413 response must be JSON, got: {body_str}"));
+    let json: Value = serde_json::from_str(body_str)
+        .unwrap_or_else(|_| panic!("413 response must be JSON, got: {body_str}"));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/tests/integration/ingest_test.rs` around lines 602 - 603, Replace
the expect(&format!(...)) call on serde_json::from_str in
apps/server/tests/integration/ingest_test.rs to avoid unconditional allocation:
call serde_json::from_str(body_str).unwrap_or_else(|_| panic!("413 response must
be JSON, got: {}", body_str)); this keeps the same panic message but builds it
only on error—locate the usage that assigns to json (let json: Value = ...) and
replace the expect with unwrap_or_else as described.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/src/error.rs`:
- Around line 64-66: The mapping for AppError::Database was changed to return
"InternalError" (see AppError::Database and the match arm returning
"InternalError"), which is intentional for H-3 but you must update documentation
and any mock test data that expects the old public contract; update the mock
event payload in apps/server/tests/integration/digest_test.rs (the test input
that contains "DatabaseError") to use "InternalError" or adjust the test comment
to clarify it's a mock, and add a note in the CHANGELOG describing this
deliberate breaking change to the JSON error_type contract for audit/security
purposes.

In `@apps/server/src/models/user.rs`:
- Around line 65-70: The run_dummy_password_verify function silently skips
Argon2 if PasswordHash::new(DUMMY_PASSWORD_HASH) fails; replace the `if let
Ok(parsed)` pattern with a forced parse (e.g.,
PasswordHash::new(DUMMY_PASSWORD_HASH).expect(...)) so malformed constant panics
immediately, and additionally parse-and-cache the resulting PasswordHash in a
OnceLock (or similar static cache) so you avoid re-parsing on every call but
still ensure the parser runs at startup; keep calling
Argon2::default().verify_password(&parsed) inside the function to preserve
timing-equalization semantics.

In `@apps/server/src/routes/ingest.rs`:
- Around line 38-40: The payload read error from payload.next() should not be
mapped to AppError::Internal; change the mapping to a client error (e.g.,
AppError::BadRequest or a new AppError::PayloadRead/BadRequest variant) and
return a 4xx response instead of a 500; update the code that currently does
chunk.map_err(|e| AppError::Internal(format!("Payload read error: {e}"))) to map
to the chosen 4xx variant (include the original error text in the message) and
avoid calling log::error! for this case (use debug or warn if you want to record
the event). Ensure you update any match/handler logic that converts AppError to
HTTP responses so this new/changed variant yields a 400-class status.

In `@apps/server/src/services/notification/webhook.rs`:
- Around line 178-183: Remove the dead wrapper function is_private_ip: it is
never called and duplicates logic already performed by is_ssrf_blocked_url_host
which calls is_private_ipv4 and is_private_ipv6 directly; delete the
is_private_ip function declaration to satisfy Clippy's dead_code lint and keep
only the direct uses of is_private_ipv4/is_private_ipv6.
- Around line 169-176: The hostname filter in is_blocked_hostname currently
misses hostnames with trailing dots (e.g., "localhost.") and so should normalize
the input by stripping any trailing dots and lowercasing before checks (e.g.,
use name.trim_end_matches('.').to_lowercase() or equivalent) and then apply the
existing blocked checks (localhost, ip6-localhost, ip6-loopback, .local,
.internal); additionally, update the webhook send() flow to mitigate DNS
rebinding by resolving the hostname at request time and re-running the
IP/address checks (or routing requests through a configured egress/proxy) so the
final destination IP cannot be an RFC1918/loopback address even if DNS changes
after config time.

In `@apps/server/tests/integration/ingest_test.rs`:
- Around line 580-598: The test currently posts 300KB but asserts a 413 even
though the handler uses streaming web::Payload and the actual enforced limits
are MAX_INGEST_PAYLOAD_BYTES (100MB), MAX_EVENT_SIZE (1MB in parser.rs), and
MAX_COMPRESSED_SIZE/MAX_DECOMPRESSED_SIZE (100MB); update the test to actually
exceed the intended limit or clarify which limit is being tested: either
increase the payload to >1MB (e.g., 1.1MB) to trigger MAX_EVENT_SIZE and keep
expecting 413, or change the test comment and assertion to reflect that 300KB
does not trigger any limit; locate the test block that builds the request and
adjust the large_body size or the expected status to match the chosen limit
(referencing MAX_EVENT_SIZE in parser.rs or
MAX_INGEST_PAYLOAD_BYTES/MAX_COMPRESSED_SIZE/MAX_DECOMPRESSED_SIZE as
appropriate).

---

Outside diff comments:
In `@apps/server/src/routes/auth.rs`:
- Around line 162-170: When the user is inactive (!user.is_active) run the same
expensive password verification as other failure paths to avoid a timing oracle:
call User::run_dummy_password_verify() (or user.verify_password(&req.password)
and ignore/handle its Result) before returning the AppError::Unauthorized;
ensure the call executes even if it fails, then return the same Unauthorized
response (consider using the same "Invalid credentials" text to avoid
message-based enumeration).

In `@apps/server/src/routes/ingest.rs`:
- Around line 33-64: The handler currently buffers the request body up to
MAX_INGEST_PAYLOAD_BYTES before performing the rate-limit check, which defeats
the "fail fast" intent; move the RateLimitService::check_quota call (using
pool.get_ref() and &auth.project) to run before the body collection loop so
rate-limited projects return TooManyRequests without allocating/reading the
body, then proceed to read the payload into buf/Bytes only after the quota check
succeeds.

---

Nitpick comments:
In `@apps/server/src/routes/ingest.rs`:
- Around line 18-49: The ingest_envelope handler currently streams the payload
and checks buf.len() + chunk.len() against MAX_INGEST_PAYLOAD_BYTES, but you
should early-reject by checking the request Content-Length and pre-size the
buffer to avoid streaming large unwanted bodies and repeated reallocations: read
req.headers() for actix_web::http::header::CONTENT_LENGTH, parse it to usize,
return AppError::PayloadTooLarge immediately if it exceeds
MAX_INGEST_PAYLOAD_BYTES, and when Content-Length is present and <=
MAX_INGEST_PAYLOAD_BYTES initialize the accumulator with
BytesMut::with_capacity(clamped_len) (falling back to a small default otherwise)
before the payload loop in ingest_envelope.
- Around line 17-18: MAX_INGEST_PAYLOAD_BYTES only limits a single request but
peak memory scales with concurrent uploads; wrap the ingest hot path
(ingest_envelope → decompress_body → EnvelopeParser) with an application-level
concurrency limiter (e.g., a tokio::sync::Semaphore or similar) to cap
simultaneous large-body processing, acquire a permit at the start of
ingest_envelope and release on completion/failure, and add basic metrics
(in_flight_ingest_bytes, ingest_rejections_payload_too_large) so we can observe
and reject when capacity is exceeded; alternatively reduce
MAX_INGEST_PAYLOAD_BYTES if 100 MB per-request is not required.
- Around line 146-156: The ingest_store handler currently accepts a _body: Bytes
extractor which invokes Actix's default payload size limit and can yield a
"payload too large" before returning the deprecation Validation error; remove
the _body: Bytes parameter from the ingest_store function signature (keeping the
function name ingest_store and the AppError::Validation return) so the handler
no longer triggers the extractor, and update any route registration that
references this handler signature if necessary; alternatively, if you prefer to
preserve a body argument, replace Bytes with a non-extracting type (e.g., remove
or accept HttpRequest only) or read the payload manually with the same 100MB
limit used by the /envelope/ path to keep behavior consistent.

In `@apps/server/src/services/notification/webhook.rs`:
- Around line 305-324: Add a unit test that covers the IPv4-mapped IPv6 case so
the to_ipv4_mapped() branch in is_private_ipv6 is exercised: create a test
function (similar to test_rejects_ipv6_loopback) that constructs a
WebhookNotifier and calls validate_config with a webhook URL using the
IPv4-mapped IPv6 loopback (e.g. "http://[::ffff:127.0.0.1]/hook") and assert it
returns Err; reference WebhookNotifier, validate_config, and
is_private_ipv6/to_ipv4_mapped in the test name or comment to make the intent
clear.
- Around line 185-233: The SSRF IP classifiers in is_private_ipv4 and
is_private_ipv6 miss several non-routable/misused ranges; update is_private_ipv4
to also treat 100.64.0.0/10, 255.255.255.255 (limited broadcast), and
224.0.0.0/4 (multicast) as private/non-routable, and update is_private_ipv6 to
treat :: (unspecified), ff00::/8 (multicast), and 64:ff9b::/96 (NAT64) as
private; additionally detect IPv4-compatible IPv6 (::a.b.c.d) by using
ip.to_ipv4() (in addition to to_ipv4_mapped()) and pass that IPv4 into
is_private_ipv4; alternatively consider replacing some hand-rolled checks with
std methods like
Ipv4Addr::is_loopback/is_private/is_link_local/is_broadcast/is_multicast/is_unspecified
and Ipv6Addr::is_loopback/is_unspecified/is_multicast where appropriate to
simplify is_private_ipv4 and is_private_ipv6.

In `@apps/server/tests/integration/auth_test.rs`:
- Around line 1150-1245: The test test_login_clears_pre_existing_session_data
currently verifies session.clear() but misses checking cookie rotation from
session.renew(); after you extract new_session_cookie (the response from the
login call) add an assertion asserting the cookie changed
(assert_ne!(new_session_cookie, session_cookie)) to ensure the session
identifier was rotated when using CookieSessionStore; place this assertion
immediately after obtaining new_session_cookie and before the subsequent request
that reads the marker.

In `@apps/server/tests/integration/ingest_test.rs`:
- Around line 602-603: Replace the expect(&format!(...)) call on
serde_json::from_str in apps/server/tests/integration/ingest_test.rs to avoid
unconditional allocation: call serde_json::from_str(body_str).unwrap_or_else(|_|
panic!("413 response must be JSON, got: {}", body_str)); this keeps the same
panic message but builds it only on error—locate the usage that assigns to json
(let json: Value = ...) and replace the expect with unwrap_or_else as described.

In `@apps/server/tests/unit/error_test.rs`:
- Around line 36-51: Add a positive assertion in
test_internal_error_does_not_leak_internal_details to ensure the generic error
text is returned (not just absence of DB details): after obtaining body via
body_string(app_err.error_response()), assert that body contains the same
generic/internal message used elsewhere (e.g., the string checked in the DB
test, such as "internal" or the standardized generic phrase) so the test
verifies presence of the public message when AppError::Internal is used.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d1a07e36-e4f0-45e1-af7f-49ec40c33727

📥 Commits

Reviewing files that changed from the base of the PR and between fa51631 and cdb666a.

📒 Files selected for processing (10)
  • apps/server/src/error.rs
  • apps/server/src/models/user.rs
  • apps/server/src/routes/auth.rs
  • apps/server/src/routes/ingest.rs
  • apps/server/src/services/notification/webhook.rs
  • apps/server/tests/integration/auth_test.rs
  • apps/server/tests/integration/ingest_test.rs
  • apps/server/tests/unit/error_test.rs
  • apps/server/tests/unit/mod.rs
  • apps/server/tests/unit/user_test.rs

Comment thread apps/server/src/error.rs
Comment on lines +64 to 66
AppError::Database(_) => "InternalError",
AppError::Internal(_) => "InternalError",
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find any remaining references to "DatabaseError" as an error_type value across the repo.
rg -nP '"DatabaseError"' -C2
# Also look for client-side handling keyed off the previous type string.
rg -nP "DatabaseError" --type=ts --type=tsx --type=js --type=rs -C2

Repository: AbianS/rustrak

Length of output: 519


🏁 Script executed:

# First, verify the exact code in error.rs around lines 64-66
sed -n '55,80p' apps/server/src/error.rs

Repository: AbianS/rustrak

Length of output: 1130


🏁 Script executed:

# Search more broadly for all "DatabaseError" references without type restrictions
rg -n "DatabaseError" --no-heading

Repository: AbianS/rustrak

Length of output: 152


🏁 Script executed:

# Check the test file that was flagged to understand its context
sed -n '130,150p' apps/server/tests/integration/digest_test.rs

Repository: AbianS/rustrak

Length of output: 780


🏁 Script executed:

# Look at the full test function to understand what it's testing
rg -B20 "DatabaseError" apps/server/tests/integration/digest_test.rs | head -50

Repository: AbianS/rustrak

Length of output: 884


🏁 Script executed:

# Check if there are any assertions or expectations about the "DatabaseError" type value
rg -A10 "DatabaseError" apps/server/tests/integration/digest_test.rs

Repository: AbianS/rustrak

Length of output: 390


🏁 Script executed:

# Check if there's an API endpoint that returns these error responses
rg -n "error_response" apps/server/src/ -A5 -B2

Repository: AbianS/rustrak

Length of output: 587


🏁 Script executed:

# Look at what calls error_response() - does it go to the HTTP response?
rg -n "error_response\|ErrorResponse" apps/server/src/ --type=rs | head -20

Repository: AbianS/rustrak

Length of output: 85


🏁 Script executed:

# Check the full context of what this test is actually validating
sed -n '100,200p' apps/server/tests/integration/digest_test.rs

Repository: AbianS/rustrak

Length of output: 3392


🏁 Script executed:

# Check if error_response() is actually used to return errors to clients over HTTP
rg -n "error_response\|impl.*ResponseError" apps/server/src/ --type=rust -B2 -A5

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Search for where Database errors would be returned in API responses
rg -n "AppError::Database" apps/server/src/ -B3 -A3 | head -40

Repository: AbianS/rustrak

Length of output: 2222


🏁 Script executed:

# Check if there are any integration tests that call the actual API and check error responses
rg -n "DatabaseError" apps/server/tests/ --type=rust -B5 -A5

Repository: AbianS/rustrak

Length of output: 964


🏁 Script executed:

# Check if error_response() is actually used to construct HTTP responses
rg -n "error_response()" apps/server/src/ -B3 -A3

Repository: AbianS/rustrak

Length of output: 463


🏁 Script executed:

# Check the ResponseError implementation to understand how errors are serialized
rg -n "impl.*ResponseError\|fn error_response" apps/server/src/error.rs -B2 -A10

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Search for any tests that actually call the API and verify error response content
rg -n "assert.*error_type\|assert.*DatabaseError\|assert.*InternalError" apps/server/tests/ -B2 -A2

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Check if error_response is actually called anywhere
rg -n "error_response" apps/server/src/ --type=rust

Repository: AbianS/rustrak

Length of output: 133


🏁 Script executed:

# Check how AppError is used in handlers - look for ResponseError trait implementation
rg -n "impl.*ResponseError.*for.*AppError" apps/server/src/ -A30

Repository: AbianS/rustrak

Length of output: 2355


🏁 Script executed:

# Look for where http errors are actually returned in handlers
rg -n "return Err\|Err(AppError" apps/server/src/handlers/ --type=rust -B2 -A2 | head -60

Repository: AbianS/rustrak

Length of output: 149


🏁 Script executed:

# Look for where AppError is returned in handlers
rg -n "Err(AppError" apps/server/src/handlers/ -B2 -A2 | head -80

Repository: AbianS/rustrak

Length of output: 125


🏁 Script executed:

# Check if there are any API integration tests that verify the actual HTTP response
find apps/server/tests -name "*.rs" -type f | xargs grep -l "assert\|expect" | head -5

Repository: AbianS/rustrak

Length of output: 270


🏁 Script executed:

# Look for tests that verify error responses contain error_type field
rg -n "error_type\|error\.type" apps/server/tests/ -B3 -A3

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Look for actual HTTP response testing - check if any tests call endpoints and check response status/body
rg -n "status\|response\|assert_eq" apps/server/tests/integration/auth_test.rs -B2 -A2 | head -80

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# See if there are any tests verifying HTTP responses contain error_type
rg -n "json\|body\|error" apps/server/tests/integration/alerts_api_test.rs -B1 -A1 | head -60

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Check if error_response is actually being tested anywhere
find apps/server/tests -name "*.rs" -exec grep -l "error_response\|InternalError" {} \;

Repository: AbianS/rustrak

Length of output: 95


🏁 Script executed:

# Check the error unit tests
cat apps/server/tests/unit/error_test.rs

Repository: AbianS/rustrak

Length of output: 2066


🏁 Script executed:

# Get the full error_response implementation to see what JSON structure is returned
sed -n '57,90p' apps/server/src/error.rs

Repository: AbianS/rustrak

Length of output: 1369


🏁 Script executed:

# Check if there's any JSON serialization in the response
rg -n "json\|serde_json\|error_type" apps/server/src/error.rs -B2 -A2

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Look at the response structure - what does the response body contain?
rg -n "HttpResponse\|Json\|json!" apps/server/src/error.rs -B2 -A2

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Get the ErrorResponse and ErrorDetail struct definitions
rg -n "struct ErrorResponse\|struct ErrorDetail" apps/server/src/ -A5

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Check if any tests actually verify the error_type value in HTTP responses
rg -n "error_type" apps/server/tests/unit/error_test.rs -B3 -A3

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Look for e2e tests that might call the API and verify error responses
rg -n "assert.*error" apps/server/tests/e2e/sentry_sdk_test.rs -B2 -A2 | head -60

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Search for ErrorResponse struct definition
rg -n "ErrorResponse\|ErrorDetail" apps/server/src/error.rs -B2 -A3

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Check if the unit tests verify error_type field value
cat apps/server/tests/unit/error_test.rs | grep -n "error_type\|InternalError\|DatabaseError"

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# See all tests in the unit error test file to check if error_type is verified anywhere
cat apps/server/tests/unit/error_test.rs

Repository: AbianS/rustrak

Length of output: 2066


🏁 Script executed:

# Check if the error_type value is actually verified in unit tests by searching for assertions on it
grep -n "error_type\|DatabaseError\|InternalError" apps/server/tests/unit/error_test.rs

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Check the complete error.rs file to find ErrorResponse/ErrorDetail struct definitions
cat apps/server/src/error.rs | head -100

Repository: AbianS/rustrak

Length of output: 3012


🏁 Script executed:

# Check if there are any tests that actually call the API and parse error responses
find apps/server/tests -name "*.rs" -type f -exec grep -l "error_response\|error.*type\|DatabaseError.*api" {} \;

Repository: AbianS/rustrak

Length of output: 359


🏁 Script executed:

# Search for API tests that might verify error response structure
rg -n "assert.*error\|response.*error" apps/server/tests/integration/ --type=rust -B2 -A2 | head -40

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Check if there are any documented breaking changes or migration notes
find . -name "CHANGELOG*" -o -name "BREAKING*" -o -name "MIGRATION*" 2>/dev/null

Repository: AbianS/rustrak

Length of output: 207


🏁 Script executed:

# Check the server CHANGELOG for any mentions of this error type change
cat apps/server/CHANGELOG.md | head -100

Repository: AbianS/rustrak

Length of output: 2785


🏁 Script executed:

# Search for any API integration tests that verify error response structure/content
cat apps/server/tests/integration/auth_test.rs | grep -n "assert\|error\|type" | head -40

Repository: AbianS/rustrak

Length of output: 1852


🏁 Script executed:

# Check what happens with the event after it's stored in digest_test - does it verify the error type?
sed -n '120,180p' apps/server/tests/integration/digest_test.rs

Repository: AbianS/rustrak

Length of output: 2022


🏁 Script executed:

# Check if there are any other references to "DatabaseError" as a literal in the codebase
rg -rn "DatabaseError" . --type=rust --type=json --type=yaml

Repository: AbianS/rustrak

Length of output: 138


🏁 Script executed:

# Verify if any actual API handlers return Database errors to clients
rg -n "AppError::Database" apps/server/src/handlers/ -B3 -A3

Repository: AbianS/rustrak

Length of output: 124


Clarify: the "DatabaseError" reference found is in test input data, not API response validation.

The change from "DatabaseError" to "InternalError" for AppError::Database errors is intentional for H-3 (preventing database-specific error leakage). A search found one reference to "DatabaseError" in apps/server/tests/integration/digest_test.rs:139, but this is a mock event payload used as input to test event grouping logic—not a validation of actual API error responses. No client-side code (frontend, SDKs, tests) in the repository that depends on parsing error_type == "DatabaseError" was found. This is a breaking change to the public JSON contract, but no actual clients are affected within this codebase. Consider documenting this security-focused change in the CHANGELOG.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/error.rs` around lines 64 - 66, The mapping for
AppError::Database was changed to return "InternalError" (see AppError::Database
and the match arm returning "InternalError"), which is intentional for H-3 but
you must update documentation and any mock test data that expects the old public
contract; update the mock event payload in
apps/server/tests/integration/digest_test.rs (the test input that contains
"DatabaseError") to use "InternalError" or adjust the test comment to clarify
it's a mock, and add a note in the CHANGELOG describing this deliberate breaking
change to the JSON error_type contract for audit/security purposes.

Comment on lines +65 to +70
pub fn run_dummy_password_verify(password: &str) -> bool {
if let Ok(parsed) = PasswordHash::new(DUMMY_PASSWORD_HASH) {
let _ = Argon2::default().verify_password(password.as_bytes(), &parsed);
}
false
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What are the Argon2::default() params in the argon2 crate v0.5.3 (m_cost, t_cost, p_cost)?

💡 Result:

Argon2::default (from argon2 crate v0.5.3) uses these Params defaults: - m_cost = 19_456 (KiB) - t_cost = 2 - p_cost = 1 These values come from the crate’s default Params constants (DEFAULT_M_COST, DEFAULT_T_COST, DEFAULT_P_COST) used as the “default set of Params” configuration for Argon2/Argon2::default. [1][2]

Citations:


🏁 Script executed:

# Locate and read the file
fd -t f "user.rs" apps/server/src/models/ 2>/dev/null | head -5

Repository: AbianS/rustrak

Length of output: 89


🏁 Script executed:

# Read the specific file to verify the code and context
cat -n apps/server/src/models/user.rs | sed -n '1,100p'

Repository: AbianS/rustrak

Length of output: 3035


🏁 Script executed:

# Search for DUMMY_PASSWORD_HASH constant and any tests
rg -n "DUMMY_PASSWORD_HASH|run_dummy_password_verify" apps/server/src/models/

Repository: AbianS/rustrak

Length of output: 326


🏁 Script executed:

# Search for tests of run_dummy_password_verify
rg -n "run_dummy_password_verify" apps/server/src/ -A 5 -B 2

Repository: AbianS/rustrak

Length of output: 1256


🏁 Script executed:

# Check if there's a test module in user.rs or separate test file
fd -t f "*.rs" apps/server/src/ | xargs rg -l "run_dummy_password_verify" 2>/dev/null

Repository: AbianS/rustrak

Length of output: 458


🏁 Script executed:

# Look for AppError definition and error handling patterns
rg -n "enum AppError|impl.*AppError" apps/server/src/error.rs | head -20

Repository: AbianS/rustrak

Length of output: 118


🏁 Script executed:

# Read the test file to check existing tests
cat -n apps/server/tests/unit/user_test.rs

Repository: AbianS/rustrak

Length of output: 1022


🏁 Script executed:

# Read the full AppError enum definition
cat -n apps/server/src/error.rs

Repository: AbianS/rustrak

Length of output: 3670


🏁 Script executed:

# Check the Argon2 crate version used
rg "argon2" apps/server/Cargo.toml

Repository: AbianS/rustrak

Length of output: 75


🏁 Script executed:

# Check if there are any hardening measures in place (e.g., OnceLock, LazyLock)
rg -n "OnceLock|LazyLock|once_cell" apps/server/src/

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

# Verify the constant format is valid by checking if any linting or build time checks exist
cat -n apps/server/Cargo.toml | head -30

Repository: AbianS/rustrak

Length of output: 918


Silent failure path defeats the timing-equalization goal.

If PasswordHash::new(DUMMY_PASSWORD_HASH) ever fails (typo, format change, Argon2 crate version bump altering parser strictness), the function silently returns false without running Argon2 — at which point H-1 regresses without any test failing. The current unit tests only assert the return value is false; they cannot distinguish "ran Argon2 then returned false" from "skipped Argon2 then returned false".

Two cheap hardenings worth considering:

  • Replace if let Ok(parsed) with .expect() — the constant is compile-time validated, so panicking on malformed input catches regressions immediately.
  • Cache the parsed PasswordHash in a OnceLock to avoid re-parsing on every login miss.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/models/user.rs` around lines 65 - 70, The
run_dummy_password_verify function silently skips Argon2 if
PasswordHash::new(DUMMY_PASSWORD_HASH) fails; replace the `if let Ok(parsed)`
pattern with a forced parse (e.g.,
PasswordHash::new(DUMMY_PASSWORD_HASH).expect(...)) so malformed constant panics
immediately, and additionally parse-and-cache the resulting PasswordHash in a
OnceLock (or similar static cache) so you avoid re-parsing on every call but
still ensure the parser runs at startup; keep calling
Argon2::default().verify_password(&parsed) inside the function to preserve
timing-equalization semantics.

Comment on lines +38 to +40
while let Some(chunk) = payload.next().await {
let chunk =
chunk.map_err(|e| AppError::Internal(format!("Payload read error: {e}")))?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid wrapping stream read failures as AppError::Internal.

A payload.next() error is typically a client-side transport problem (aborted connection, malformed chunked encoding, TLS reset) rather than a server fault. Mapping it to AppError::Internal produces a 500 + a server-side log::error! entry for what is in most cases not an internal bug, which will pollute alerting and metrics. A 400-class error (or a dedicated BadRequest) better reflects the cause.

♻️ Suggested fix
-            let chunk =
-                chunk.map_err(|e| AppError::Internal(format!("Payload read error: {e}")))?;
+            let chunk = chunk.map_err(|e| {
+                log::warn!("Payload read error from client: {e}");
+                AppError::Validation("Failed to read request body".to_string())
+            })?;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
while let Some(chunk) = payload.next().await {
let chunk =
chunk.map_err(|e| AppError::Internal(format!("Payload read error: {e}")))?;
while let Some(chunk) = payload.next().await {
let chunk = chunk.map_err(|e| {
log::warn!("Payload read error from client: {e}");
AppError::Validation("Failed to read request body".to_string())
})?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/routes/ingest.rs` around lines 38 - 40, The payload read
error from payload.next() should not be mapped to AppError::Internal; change the
mapping to a client error (e.g., AppError::BadRequest or a new
AppError::PayloadRead/BadRequest variant) and return a 4xx response instead of a
500; update the code that currently does chunk.map_err(|e|
AppError::Internal(format!("Payload read error: {e}"))) to map to the chosen 4xx
variant (include the original error text in the message) and avoid calling
log::error! for this case (use debug or warn if you want to record the event).
Ensure you update any match/handler logic that converts AppError to HTTP
responses so this new/changed variant yields a 400-class status.

Comment on lines +169 to +176
fn is_blocked_hostname(name: &str) -> bool {
let lower = name.to_lowercase();
lower == "localhost"
|| lower == "ip6-localhost"
|| lower == "ip6-loopback"
|| lower.ends_with(".local")
|| lower.ends_with(".internal")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Quickly confirm url crate normalization behavior for trailing dot / uppercase / numeric IPv4 forms.
# (No repo lookup needed; this is a sanity check on the url crate version in use.)
rg -nP '^\s*url\s*=' apps/server/Cargo.toml

Repository: AbianS/rustrak

Length of output: 75


🏁 Script executed:

cat -n apps/server/src/services/notification/webhook.rs | head -200 | tail -50

Repository: AbianS/rustrak

Length of output: 1808


🏁 Script executed:

rg -n "is_blocked_hostname|is_private_ip" apps/server/src/services/notification/webhook.rs -B 3 -A 3

Repository: AbianS/rustrak

Length of output: 1426


🏁 Script executed:

rg -n "is_private_ip" apps/server/src/ -t rust

Repository: AbianS/rustrak

Length of output: 893


🏁 Script executed:

rg -n "is_ssrf_blocked_url_host|validate_webhook_url" apps/server/src/services/notification/webhook.rs -B 5 -A 5

Repository: AbianS/rustrak

Length of output: 885


🏁 Script executed:

rg -n "validate_webhook_url" apps/server/src/ -t rust

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

cat -n apps/server/src/services/notification/webhook.rs | head -160 | tail -50

Repository: AbianS/rustrak

Length of output: 2233


🏁 Script executed:

rg -n "fn validate|struct.*Webhook|impl.*Webhook" apps/server/src/services/notification/webhook.rs -B 2 -A 5 | head -100

Repository: AbianS/rustrak

Length of output: 1171


🏁 Script executed:

cat -n apps/server/src/services/notification/webhook.rs | head -90 | tail -30

Repository: AbianS/rustrak

Length of output: 1347


🏁 Script executed:

cd apps/server && python3 << 'EOF'
import subprocess
import sys

# Check url crate documentation and behavior for trailing dots
# We'll create a quick test to see how url::Url parses domains with trailing dots
test_code = '''
use url::Url;

fn main() {
    let test_urls = vec![
        "http://localhost./webhook",
        "http://localhost/webhook",
        "http://example.com./webhook",
        "http://LOCALHOST/webhook",
        "http://LOcalhost/webhook",
    ];
    
    for url_str in test_urls {
        if let Ok(url) = Url::parse(url_str) {
            if let Some(host) = url.host() {
                println!("URL: {} => Host: {:?}", url_str, host);
            }
        }
    }
}
'''

print("Note: url crate v2.5.8 behavior with trailing dots:")
print("url::Url::parse will include trailing dots in domain names.")
print("url::Host<&str>::Domain preserves the exact string including dots.")
print()
print("The is_blocked_hostname function receives the domain string as-is,")
print("so 'localhost.' would NOT match the exact string comparison with 'localhost'")
EOF

Repository: AbianS/rustrak

Length of output: 382


🏁 Script executed:

rg -n "validate_config|WebhookNotifier" apps/server/src/ -t rust | head -30

Repository: AbianS/rustrak

Length of output: 3450


🏁 Script executed:

rg -n "localhost\." apps/server/src/services/notification/webhook.rs

Repository: AbianS/rustrak

Length of output: 40


🏁 Script executed:

cat -n apps/server/src/services/notification/webhook.rs | sed -n '249,315p'

Repository: AbianS/rustrak

Length of output: 2790


🏁 Script executed:

cat -n apps/server/src/services/notification/webhook.rs | sed -n '313,330p'

Repository: AbianS/rustrak

Length of output: 762


🏁 Script executed:

rg -n "send_alert|send\(" apps/server/src/services/notification/webhook.rs -A 3 | head -30

Repository: AbianS/rustrak

Length of output: 381


🏁 Script executed:

rg -n "resolve|dns|ip" apps/server/src/services/notification/webhook.rs -i

Repository: AbianS/rustrak

Length of output: 966


Hostname filter can be bypassed with trailing dots; also note DNS rebinding risk.

Two valid concerns:

  1. lower == "localhost" won't match the FQDN form "localhost." (trailing dot). The url crate v2.5.8 preserves trailing dots in domain strings, and is_blocked_hostname receives the string as-is without normalization. Worth fixing with lower.trim_end_matches('.') or similar.

  2. SSRF validation runs only at webhook config time. An attacker-controlled public domain configured at setup can still resolve to RFC1918 addresses at request time (DNS rebinding). The send() method uses the stored URL without re-validating the resolved IP. If this server has access to internal networks, consider resolving the hostname at send time and re-applying IP checks, or routing through an egress proxy.

The first is a low-effort fix; the second is an operational consideration.

♻️ Suggested normalization for point 1
 fn is_blocked_hostname(name: &str) -> bool {
-    let lower = name.to_lowercase();
-    lower == "localhost"
-        || lower == "ip6-localhost"
-        || lower == "ip6-loopback"
-        || lower.ends_with(".local")
-        || lower.ends_with(".internal")
+    let lower = name.to_lowercase();
+    let host = lower.trim_end_matches('.');
+    host == "localhost"
+        || host == "ip6-localhost"
+        || host == "ip6-loopback"
+        || host.ends_with(".local")
+        || host.ends_with(".internal")
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn is_blocked_hostname(name: &str) -> bool {
let lower = name.to_lowercase();
lower == "localhost"
|| lower == "ip6-localhost"
|| lower == "ip6-loopback"
|| lower.ends_with(".local")
|| lower.ends_with(".internal")
}
fn is_blocked_hostname(name: &str) -> bool {
let lower = name.to_lowercase();
let host = lower.trim_end_matches('.');
host == "localhost"
|| host == "ip6-localhost"
|| host == "ip6-loopback"
|| host.ends_with(".local")
|| host.ends_with(".internal")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/services/notification/webhook.rs` around lines 169 - 176, The
hostname filter in is_blocked_hostname currently misses hostnames with trailing
dots (e.g., "localhost.") and so should normalize the input by stripping any
trailing dots and lowercasing before checks (e.g., use
name.trim_end_matches('.').to_lowercase() or equivalent) and then apply the
existing blocked checks (localhost, ip6-localhost, ip6-loopback, .local,
.internal); additionally, update the webhook send() flow to mitigate DNS
rebinding by resolving the hostname at request time and re-running the
IP/address checks (or routing requests through a configured egress/proxy) so the
final destination IP cannot be an RFC1918/loopback address even if DNS changes
after config time.

Comment on lines +178 to +183
fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => is_private_ipv4(v4),
std::net::IpAddr::V6(v6) => is_private_ipv6(v6),
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm there are no other callers of `is_private_ip` in the crate.
rg -nP '\bis_private_ip\s*\(' --type=rust

Repository: AbianS/rustrak

Length of output: 160


🏁 Script executed:

# Read the webhook.rs file to see the full context of is_private_ip and is_ssrf_blocked_url_host
cat apps/server/src/services/notification/webhook.rs | head -220 | tail -100

Repository: AbianS/rustrak

Length of output: 3174


is_private_ip is unused — remove this dead code.

The function has no callers in the codebase. is_ssrf_blocked_url_host handles IP validation by dispatching directly to is_private_ipv4 and is_private_ipv6, bypassing this wrapper entirely. Clippy's dead_code lint will flag this during CI.

♻️ Suggested removal
-fn is_private_ip(ip: std::net::IpAddr) -> bool {
-    match ip {
-        std::net::IpAddr::V4(v4) => is_private_ipv4(v4),
-        std::net::IpAddr::V6(v6) => is_private_ipv6(v6),
-    }
-}
-
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => is_private_ipv4(v4),
std::net::IpAddr::V6(v6) => is_private_ipv6(v6),
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/src/services/notification/webhook.rs` around lines 178 - 183,
Remove the dead wrapper function is_private_ip: it is never called and
duplicates logic already performed by is_ssrf_blocked_url_host which calls
is_private_ipv4 and is_private_ipv6 directly; delete the is_private_ip function
declaration to satisfy Clippy's dead_code lint and keep only the direct uses of
is_private_ipv4/is_private_ipv6.

Comment on lines +580 to +598
// 300KB plain body — exceeds actix-web default 256KB Bytes extractor limit
let large_body = vec![b'x'; 300 * 1024];

let req = test::TestRequest::post()
.uri(&format!("/api/{}/envelope/", project_id))
.insert_header((
"X-Sentry-Auth",
format!("Sentry sentry_key={}, sentry_version=7", sentry_key),
))
.set_payload(large_body)
.to_request();

let resp = test::call_service(&app, req).await;
assert_eq!(
resp.status(),
413,
"Oversized body must return 413, not {}",
resp.status()
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the ingest handler and identify the actual payload cap constant/literal it enforces.
fd -t f 'ingest.rs' apps/server/src
rg -nP -C5 '(MAX_INGEST_PAYLOAD_BYTES|PayloadTooLarge|to_bytes|Payload)' apps/server/src/routes/ingest.rs
rg -nP -C2 'MAX_INGEST_PAYLOAD_BYTES\s*[:=]'

Repository: AbianS/rustrak

Length of output: 1700


🏁 Script executed:

# View the test to understand what it's testing
head -n 610 apps/server/tests/integration/ingest_test.rs | tail -n 50

Repository: AbianS/rustrak

Length of output: 1720


🏁 Script executed:

# Check if there are other payload size tests
rg -n 'large_body|oversized|payload.*size|413' apps/server/tests/integration/ingest_test.rs

Repository: AbianS/rustrak

Length of output: 353


🏁 Script executed:

# Search for any configuration of actix-web limits in the test setup
rg -n 'HttpServer|actix|JsonConfig|PayloadConfig|256|limit' apps/server/tests/integration/ingest_test.rs | head -30

Repository: AbianS/rustrak

Length of output: 1291


🏁 Script executed:

# Search for any actix payload configuration or default limits
rg -n 'payload_limit|max_body_size|PayloadSize|256|DEFAULT' apps/server/src --type rs

Repository: AbianS/rustrak

Length of output: 85


🏁 Script executed:

# Check actix-web version and see if there's implicit payload limiting
rg -n 'actix-web' apps/server/Cargo.toml

Repository: AbianS/rustrak

Length of output: 328


🏁 Script executed:

# Look at the error response handling to see how PayloadTooLarge becomes 413
rg -n -A5 'impl.*Error|PayloadTooLarge|to_response' apps/server/src/errors.rs | head -50

Repository: AbianS/rustrak

Length of output: 124


🏁 Script executed:

# Find error-related files
fd -t f 'error' apps/server/src

Repository: AbianS/rustrak

Length of output: 83


🏁 Script executed:

# Search for PayloadTooLarge error definition and handling
rg -n 'PayloadTooLarge' apps/server/src

Repository: AbianS/rustrak

Length of output: 789


🏁 Script executed:

# Check main.rs or lib.rs to understand error handling
rg -n 'AppError|to_response|impl.*Error' apps/server/src --max-count=50

Repository: AbianS/rustrak

Length of output: 19266


🏁 Script executed:

# Read the error.rs file to see PayloadTooLarge handling
cat -n apps/server/src/error.rs

Repository: AbianS/rustrak

Length of output: 3670


🏁 Script executed:

# Check decompression.rs and parser.rs to see if they also check payload limits
cat -n apps/server/src/ingest/decompression.rs | head -50

Repository: AbianS/rustrak

Length of output: 2053


🏁 Script executed:

# Check what triggers PayloadTooLarge in the decompression and parser
rg -n -B2 -A2 'PayloadTooLarge' apps/server/src/ingest/

Repository: AbianS/rustrak

Length of output: 1711


🏁 Script executed:

# Check MAX_EVENT_SIZE constant in parser.rs
rg -n 'MAX_EVENT_SIZE' apps/server/src/ingest/parser.rs

Repository: AbianS/rustrak

Length of output: 232


🏁 Script executed:

# Look at the full parser.rs to see all size limits
head -n 100 apps/server/src/ingest/parser.rs

Repository: AbianS/rustrak

Length of output: 3075


🏁 Script executed:

# Check the test to see if it verifies the error message content
sed -n '590,610p' apps/server/tests/integration/ingest_test.rs

Repository: AbianS/rustrak

Length of output: 624


Fix the test to accurately reflect payload size limits.

The test sends 300KB and expects PayloadTooLarge (413), but 300KB does not exceed any of the actual limits in the code:

  • Raw payload collection checks against MAX_INGEST_PAYLOAD_BYTES = 100MB (line 41–44 of ingest.rs)
  • Individual envelope items are checked against MAX_EVENT_SIZE = 1MB (line 62–65 of parser.rs)
  • Decompression checks against MAX_COMPRESSED_SIZE and MAX_DECOMPRESSED_SIZE (both 100MB in decompression.rs)

Since the handler uses web::Payload (streaming) rather than the web::Bytes extractor, the test comment about "actix-web default 256KB limit" is incorrect. Either send a body large enough to actually exceed one of the limits (e.g., >1MB to trigger MAX_EVENT_SIZE), or clarify which limit is being tested. The test assertion should match the limit it intends to verify.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/tests/integration/ingest_test.rs` around lines 580 - 598, The
test currently posts 300KB but asserts a 413 even though the handler uses
streaming web::Payload and the actual enforced limits are
MAX_INGEST_PAYLOAD_BYTES (100MB), MAX_EVENT_SIZE (1MB in parser.rs), and
MAX_COMPRESSED_SIZE/MAX_DECOMPRESSED_SIZE (100MB); update the test to actually
exceed the intended limit or clarify which limit is being tested: either
increase the payload to >1MB (e.g., 1.1MB) to trigger MAX_EVENT_SIZE and keep
expecting 413, or change the test comment and assertion to reflect that 300KB
does not trigger any limit; locate the test block that builds the request and
adjust the large_body size or the expected status to match the chosen limit
(referencing MAX_EVENT_SIZE in parser.rs or
MAX_INGEST_PAYLOAD_BYTES/MAX_COMPRESSED_SIZE/MAX_DECOMPRESSED_SIZE as
appropriate).

#[actix_web::test]
async fn test_register_rejects_password_shorter_than_8_chars() {
let db = TestDb::new().await;
let session_key = Key::from(&[0u8; 64]);
#[actix_web::test]
async fn test_register_rejects_password_longer_than_1024_chars() {
let db = TestDb::new().await;
let session_key = Key::from(&[0u8; 64]);
#[actix_web::test]
async fn test_register_accepts_password_at_exact_boundaries() {
let db = TestDb::new().await;
let session_key = Key::from(&[0u8; 64]);
#[actix_web::test]
async fn test_login_rejects_password_longer_than_1024_chars() {
let db = TestDb::new().await;
let session_key = Key::from(&[0u8; 64]);

let db = TestDb::new().await;
let config = create_test_config();
let session_key = Key::from(&[0u8; 64]);
let config = create_test_config();
let session_key = Key::from(&[0u8; 64]);

create_test_user(&db.pool, "sessfix@example.com", "password123", false).await;
#[test]
fn test_dummy_password_verify_always_returns_false() {
// Must never grant access — runs Argon2 to equalize timing with real verify
assert!(!User::run_dummy_password_verify("anypassword"));

#[test]
fn test_dummy_password_verify_with_empty_password() {
assert!(!User::run_dummy_password_verify(""));
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.

2 participants