Skip to content

fix: security hardening — OAuth2, HMAC, MFA timing, cURL, PRNG#12310

Open
Gurjit-30 wants to merge 1 commit into
appwrite:1.9.xfrom
Gurjit-30:fix-security-hardening-audit
Open

fix: security hardening — OAuth2, HMAC, MFA timing, cURL, PRNG#12310
Gurjit-30 wants to merge 1 commit into
appwrite:1.9.xfrom
Gurjit-30:fix-security-hardening-audit

Conversation

@Gurjit-30
Copy link
Copy Markdown

What does this PR do?

This PR implements multiple security hardening improvements and bug fixes identified during a comprehensive codebase audit:

  1. Fixed Etsy OAuth2 implementation (Critical)
    • getUser() constructed an Authorization header but never passed it to the HTTP request, causing all profile API calls to fail.
    • The PKCE verifier generation used rand() for its length and produced verifiers up to 256 characters long, violating the RFC 7636 maximum of 128 characters. Fixed to use a deterministic cryptographically secure 64 bytes (128 hex chars).
  2. Upgraded Webhook HMAC to SHA-256 (Security)
    • Replaced the deprecated sha1 hashing algorithm with sha256 for webhook signatures in Webhooks.php, aligning with industry standards (GitHub, Stripe, etc.).
  3. Prevented MFA Timing Attacks (Security)
    • Replaced in_array() with a timing-safe hash_equals() loop for MFA recovery code comparison in Update.php.
  4. Hardened Assistant Endpoint (Bug/Security)
    • Added missing Content-Type: application/json header to the SSE request.
    • Reduced excessive 9000-second (2.5 hour) cURL timeout to 300 seconds to prevent potential resource exhaustion / DoS.
  5. General Hardening
    • Corrected CURLOPT_SSL_VERIFYHOST from boolean false to integer 0 per cURL specifications in Webhooks.php and Executor.php.
    • Replaced rand() with random_int() in app/http.php for uniform worker dispatch distribution.

Test Plan

  1. Etsy OAuth2: Trigger the Etsy OAuth flow and verify that user information is successfully retrieved without authentication errors, and that the PKCE challenge strictly meets RFC length requirements.
  2. Webhooks: Create a webhook and trigger it. Verify the X-Appwrite-Webhook-Signature header validates correctly using hash_hmac('sha256', ...) on the receiving end.
  3. MFA Recovery: Attempt to use an MFA recovery code and ensure successful login on match, and rejection on mismatch.
  4. Assistant: Trigger an assistant prompt and verify that the request succeeds (now sending the correct content type).
  5. Worker Dispatch: Ensure Appwrite boots normally and routes requests correctly to Swoole workers without errors.

Related PRs and Issues

  • Resolves security audit findings.

Checklist

  • Have you read the Contributing Guidelines on issues?
  • If the PR includes a change to an API's metadata (desc, label, params, etc.), does it also include updated API specs and example docs?

- Fix Etsy OAuth2 getUser() missing Authorization header argument

- Fix Etsy PKCE verifier using weak rand() and violating RFC 7636 max length

- Upgrade webhook HMAC from SHA-1 to SHA-256

- Use hash_equals() for MFA recovery code comparison (timing-safe)

- Add missing Content-Type header to assistant endpoint

- Reduce assistant cURL timeout from 9000s to 300s

- Fix CURLOPT_SSL_VERIFYHOST false to 0 per cURL spec

- Replace rand() with random_int() in worker dispatch
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 14, 2026

Greptile Summary

This PR applies a batch of security hardening fixes across six files, addressing findings from a codebase audit. Most changes are straightforward improvements, but one requires extra care before merging.

  • Etsy OAuth2: Fixed two bugs — the Authorization header that was built but never sent in getUser(), and a PKCE verifier that could exceed RFC 7636's 128-character maximum (now a deterministic 64 bytes → 128 hex chars).
  • Webhook signature: Upgraded HMAC from SHA-1 to SHA-256. This is the right direction but is a silent breaking change — every existing consumer verifying the X-Appwrite-Webhook-Signature header with SHA-1 will immediately fail after deployment, with no version indicator or migration path.
  • MFA recovery codes: Replaced in_array() with a hash_equals() loop to prevent timing attacks; rand() replaced with random_int() for worker dispatch; CURLOPT_SSL_VERIFYHOST corrected from false to 0; missing Content-Type: application/json added to the assistant cURL request; and the assistant's 9000-second timeout reduced to 300 seconds.

Confidence Score: 3/5

The Etsy, cURL, PRNG, and MFA changes are safe to merge, but the webhook HMAC algorithm switch will immediately break all existing consumers validating signatures and needs a migration strategy before shipping.

The webhook SHA-1 → SHA-256 change has no backward-compatibility mechanism. Any deployed integration or SDK snippet that verifies X-Appwrite-Webhook-Signature will silently reject every delivery the moment this lands in production, with no indication of why. That affects every webhook user without any action on their part.

src/Appwrite/Platform/Workers/Webhooks.php needs a migration plan before the SHA-256 switch goes out.

Important Files Changed

Filename Overview
src/Appwrite/Platform/Workers/Webhooks.php Upgraded webhook HMAC from SHA-1 to SHA-256 and corrected CURLOPT_SSL_VERIFYHOST to integer 0; the algorithm change is a silent breaking change for all existing consumers validating signatures.
src/Appwrite/Auth/OAuth2/Etsy.php Fixed critical bug where Authorization header was never passed to the profile API request, and corrected PKCE verifier length to always produce exactly 128 hex characters (RFC 7636 compliant).
src/Appwrite/Platform/Modules/Account/Http/Account/MFA/Challenges/Update.php Replaced in_array with hash_equals loop for timing-safe MFA recovery code comparison; early break still leaks minor timing information about code position.
src/Appwrite/Platform/Modules/Console/Http/Assistant/Create.php Added missing Content-Type header and reduced timeout from 9000s to 300s; 300s may prematurely terminate long-running SSE streams.
app/http.php Replaced rand() with random_int() for cryptographically secure worker dispatch selection; straightforward and correct improvement.
src/Executor/Executor.php Corrected CURLOPT_SSL_VERIFYHOST from boolean false to integer 0 per cURL specification; safe and correct fix.

Reviews (1): Last reviewed commit: "fix: security hardening — OAuth2, HMAC, ..." | Re-trigger Greptile

$url = \rawurldecode($webhook->getAttribute('url'));
$signatureKey = $webhook->getAttribute('signatureKey');
$signature = base64_encode(hash_hmac('sha1', $url . $payload, $signatureKey, true));
$signature = base64_encode(hash_hmac('sha256', $url . $payload, $signatureKey, true));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Breaking change: SHA-256 signature will silently fail all existing webhook consumers

Switching from sha1 to sha256 is the right long-term direction, but it is a breaking change for every integration that is currently verifying the X-Appwrite-Webhook-Signature header. The moment this is deployed, any consumer validating with hash_hmac('sha1', …) will get a mismatched signature on every delivery with no error message or fallback. There is no version indicator in the header, no dual-emission period, and no mention of this in the changelog or migration guide. Existing SDK snippets and third-party integrations will silently reject all webhooks.

Comment on lines +117 to +122
foreach ($mfaRecoveryCodes as $code) {
if (\hash_equals($code, $otp)) {
$found = true;
break;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The early break partially defeats the purpose of switching to hash_equals. The string comparison for each individual code is now constant-time, but the loop exits at the first match, so a valid code at position 0 returns faster than one at position 7. An attacker who can make many rapid requests may be able to distinguish how many codes were iterated over. Removing break and letting the loop run to completion gives genuinely constant-time behavior across the full recovery code set.

Suggested change
foreach ($mfaRecoveryCodes as $code) {
if (\hash_equals($code, $otp)) {
$found = true;
break;
}
}
foreach ($mfaRecoveryCodes as $code) {
if (\hash_equals($code, $otp)) {
$found = true;
}
}

curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 0);
curl_setopt($ch, CURLOPT_TIMEOUT, 9000);
curl_setopt($ch, CURLOPT_TIMEOUT, 300);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 300-second CURLOPT_TIMEOUT may prematurely cut off SSE streaming

CURLOPT_TIMEOUT is a wall-clock limit on the entire transfer, including the streaming phase. For a Server-Sent Events connection where the assistant may produce tokens for several minutes, 300 seconds (5 minutes) can terminate a legitimately active stream. Since CURLOPT_CONNECTTIMEOUT is already set to 0 for the connection phase, consider using a transfer-specific guard (e.g., CURLOPT_LOW_SPEED_LIMIT / CURLOPT_LOW_SPEED_TIME) rather than a hard total-transfer timeout, or raise the limit to something that still caps truly stalled connections.

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.

1 participant