Skip to content

feat: constant-time HMAC-SHA256 - #635

Merged
PRODHOSH merged 1 commit into
PRODHOSH:mainfrom
Aditya8369:feat/constant-time-HMAC-SHA256
Jul 26, 2026
Merged

feat: constant-time HMAC-SHA256#635
PRODHOSH merged 1 commit into
PRODHOSH:mainfrom
Aditya8369:feat/constant-time-HMAC-SHA256

Conversation

@Aditya8369

@Aditya8369 Aditya8369 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Related Issue

Closes #557

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Refactor
  • Chore / dependency update

Changes Made

  • Refactored verifySignature in route.ts to compute binary Uint8Array HMAC-SHA256 digests and compare signatures in constant time via crypto.subtle.timingSafeEqual, eliminating timing attack side channels inside Cloudflare Worker execution bounds.
  • Added webhook-dead-letter.ts and retry endpoint route.ts
    to automatically record unhandled payload events during transient lock contention and process them on schedule.

Checklist

  • I was assigned to the issue before opening this PR
  • My branch is up to date with main
  • Code works locally and I have tested it
  • No console.log left in src/
  • If schema changed — both schema.sql and a new migration file are included
  • If this is a UI change — I read DESIGN.md and followed the design system (colors, spacing, typography, components)
  • Docs updated if needed
  • PR title follows Conventional Commits format (feat:, fix:, docs:, etc.)
  • This PR description is written in my own words

Summary by CodeRabbit

  • New Features

    • Added automatic retry processing for failed GitHub webhook events.
    • Failed profile refreshes are retained and rescheduled with retry limits and backoff.
    • Added webhook retry status reporting, including processed, successful, failed, and retried counts.
  • Bug Fixes

    • Improved GitHub webhook signature validation for more reliable request authentication.
  • Tests

    • Added coverage for signature validation, webhook handling, dead-letter storage, and retry behavior.

@github-actions

Copy link
Copy Markdown
Contributor

Welcome to OSSfolio, @Aditya8369! 🎉

Thank you for opening this pull request and contributing to the open-source community! 🚀

To ensure a smooth review process, please make sure you have:

  • Checked that your changes work locally and compile cleanly.
  • Followed the guidelines outlined in CONTRIBUTING.md.
  • Linked your PR to an open issue (e.g. Closes #ISSUE_NUMBER).

We will review your PR as soon as possible. Happy coding! 💻✨

@github-actions github-actions Bot added frontend Related to UI / Next.js backend API / Database / Server labels Jul 26, 2026
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

GitHub webhook verification now uses Web Crypto byte comparisons. Failed profile refreshes are stored in a Supabase dead-letter queue with retry scheduling, and a protected endpoint processes queued entries. Tests cover signature validation, webhook handling, queue insertion, processing, and retries.

Changes

GitHub webhook reliability

Layer / File(s) Summary
Dead-letter storage and retry processing
supabase/migrations/..., src/lib/webhook-dead-letter.ts, src/lib/__tests__/webhook-dead-letter.test.ts
Adds the queue schema, indexes, RLS, insertion and retry-processing functions, exponential backoff, status transitions, and tests.
Edge-safe webhook signature verification
src/app/api/webhooks/github/route.ts, src/lib/__tests__/github-webhook.test.ts
Decodes sha256= signatures and compares HMAC bytes with constant-time buffer comparison, with coverage for valid and invalid signatures.
Webhook refresh failure integration
src/app/api/webhooks/github/route.ts, src/lib/__tests__/github-webhook.test.ts
Retains parsed payloads and persists refresh errors or exceptions while returning successful webhook responses.
Retry endpoint wiring
src/app/api/webhooks/github/retry/route.ts
Adds authorized GET and POST handlers that process the dead-letter queue with a configurable batch size.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • PRODHOSH/ossfolio#310: Earlier GitHub webhook receiver and refreshProfile integration used by this change.

Suggested labels: VETERAN

Suggested reviewers: prodhosh

Sequence Diagram(s)

sequenceDiagram
  participant GitHub
  participant WebhookRoute
  participant RefreshProfile
  participant DeadLetterQueue
  GitHub->>WebhookRoute: Send signed webhook
  WebhookRoute->>RefreshProfile: Refresh contributor profile
  RefreshProfile-->>WebhookRoute: Return status or throw error
  WebhookRoute->>DeadLetterQueue: Enqueue failed payload
  WebhookRoute-->>GitHub: Return webhook response
Loading
sequenceDiagram
  participant AdminClient
  participant RetryRoute
  participant DeadLetterProcessor
  participant RefreshProfile
  AdminClient->>RetryRoute: POST retry request with batch size
  RetryRoute->>DeadLetterProcessor: Process due queue entries
  DeadLetterProcessor->>RefreshProfile: Retry profile refresh
  RefreshProfile-->>DeadLetterProcessor: Return refresh result
  DeadLetterProcessor-->>RetryRoute: Return processing statistics
  RetryRoute-->>AdminClient: Return success and stats
Loading

Poem

I’m a rabbit guarding the queue,
With signed bytes hopping true.
Failed refreshes rest, then retry,
Backoff clocks tick by.
Webhooks return with cheerful cheer—
Safe little payloads persevere. 🐇

🚥 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 is concise and accurately reflects one major change: constant-time HMAC-SHA256 verification.
Linked Issues check ✅ Passed The PR implements constant-time HMAC verification, dead-letter persistence, and retry processing required by #557.
Out of Scope Changes check ✅ Passed The changes stay focused on webhook verification, dead-letter storage, retry logic, and tests, with no clear unrelated additions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 12

🤖 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 `@src/app/api/webhooks/github/retry/route.ts`:
- Around line 15-21: Clamp the parsed batchSize in the retry route before
passing it to processDeadLetterQueue, treating invalid or missing values as the
default and restricting valid values to the safe 1–100 range. Update the
existing batchSize parsing/validation flow while preserving the current
processDeadLetterQueue call.
- Around line 6-13: Update the authorization check in the retry route to return
a 503 “secret missing” response when neither SUPABASE_SERVICE_ROLE_KEY nor
GITHUB_WEBHOOK_SECRET is configured, before comparing credentials or queueing
work. Then compare the provided bearer token with the configured secret using a
timing-safe comparison, while preserving the existing 401 response for invalid
credentials.

In `@src/app/api/webhooks/github/route.ts`:
- Around line 139-144: Limit the dead-letter payload in the webhook handler’s
enqueueDeadLetterPayload calls to only fields required for retry, such as owner
and parsedPayload.ref, instead of passing the full parsedPayload containing PII.
Apply this change to both the refresh-status failure path and the second enqueue
call around the referenced lines, while preserving the existing owner fallback.
- Around line 136-145: Update refreshProfile status handling to distinguish
intentional 10-minute throttling from retryable lock-contention or refresh
failures by introducing a non-retryable Throttled variant separate from
RateLimited. In the webhook flow around refreshProfile and
enqueueDeadLetterPayload, enqueue only retryable error/lock-contention outcomes,
excluding Throttled, and update related webhook/dead-letter retry checks to
preserve this distinction.
- Around line 26-57: Update timingSafeEqualBuffer to normalize both inputs into
byte-addressed Uint8Array views that preserve each source view’s byteOffset and
byteLength before any native timingSafeEqual call. Validate equal byte lengths
first, then invoke the native API with the normalized views; retain the
constant-time manual comparison fallback for environments without the native
method.

In `@src/lib/__tests__/github-webhook.test.ts`:
- Around line 183-207: Extend the GitHub webhook tests alongside the existing
“background refresh encounters error” case to cover both remaining dead-letter
branches: mock refreshProfile to return status “rate_limited” and assert
enqueueDeadLetterPayload uses the corresponding errorReason, then mock it to
throw an Error and assert the thrown message is used as errorReason. Preserve
the existing successful response and payload assertions.

In `@src/lib/__tests__/webhook-dead-letter.test.ts`:
- Around line 79-188: Add a test in the processDeadLetterQueue suite covering a
refresh failure when retry_count + 1 >= max_retries. Mock the pending item and
refreshProfile response, invoke processDeadLetterQueue, then assert the returned
stats report one processed and failed item with no retry, and verify mockUpdate
marks the item status as failed.

In `@src/lib/webhook-dead-letter.ts`:
- Around line 44-58: Sanitize and minimize the payload before the insert in the
webhook dead-letter write flow, rather than persisting input.payload verbatim.
Update the code around the webhook_dead_letter_queue insert to remove or redact
sensitive and unnecessary GitHub fields while preserving the data required for
retry and diagnosis, and ensure the stored payload remains bounded for
indefinite retention.
- Around line 1-2: Add the `server-only` import at the top of the
`webhook-dead-letter` module before its existing imports, preserving the current
`supabaseAdmin` and `refreshProfile` behavior while preventing accidental client
bundling.
- Around line 91-116: Update the row-claiming update in processDeadLetterQueue
to atomically claim only rows still in pending status by adding a status
predicate alongside the existing id filter. Ensure subsequent processing
continues only when that claim succeeds, so concurrent invocations skip rows
already claimed by another run.
- Around line 103-164: Update the loop handling in the dead-letter processing
function to isolate each item with a per-item try/catch, ensuring exceptions
from refreshProfile or status updates do not abort the batch and leave rows in
processing. Check the returned error from every webhook_dead_letter_queue
update, and route any failure through the item’s failure/recovery handling
before updating result counters; preserve the existing completed, failed, and
pending retry transitions for successful writes.

In `@supabase/migrations/20260726000001_add_webhook_dead_letters.sql`:
- Around line 5-19: The webhook_dead_letter_queue schema retains complete
payloads indefinitely; add a retention mechanism for terminal completed/failed
rows, such as an indexed scheduled cleanup with a defined retention window, and
redact sensitive webhook fields before persistence where applicable. Keep
pending and processing retry data available and preserve service-role-only
access.
🪄 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: ASSERTIVE

Plan: Pro Plus

Run ID: 54234d33-03fe-41b0-b51b-b9f430e6a774

📥 Commits

Reviewing files that changed from the base of the PR and between cb7ae6e and 248bbce.

📒 Files selected for processing (6)
  • src/app/api/webhooks/github/retry/route.ts
  • src/app/api/webhooks/github/route.ts
  • src/lib/__tests__/github-webhook.test.ts
  • src/lib/__tests__/webhook-dead-letter.test.ts
  • src/lib/webhook-dead-letter.ts
  • supabase/migrations/20260726000001_add_webhook_dead_letters.sql

Comment on lines +6 to +13
// Simple administrative authorization check via header or service role token
const authHeader = request.headers.get("authorization");
const secretKey =
process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.GITHUB_WEBHOOK_SECRET;

if (secretKey && authHeader !== `Bearer ${secretKey}`) {
return createErrorResponse("Unauthorized", 401);
}

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.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Web Crypto crypto.subtle.timingSafeEqual exist, or is timing-safe byte comparison implemented manually in Node/Edge runtimes?

💡 Result:

The Web Crypto API standard does not include crypto.subtle.timingSafeEqual [1][2][3]. Therefore, it is not available in standard web browsers or generic edge runtimes [2][3]. Regarding the runtimes: Node.js: Node.js provides a native, constant-time crypto.timingSafeEqual(a, b) function via its node:crypto module [4]. This is implemented in C++ (using CRYPTO_memcmp from OpenSSL) to ensure the comparison is resistant to timing attacks [5][6]. It is not part of the crypto.subtle (Web Crypto) namespace in Node.js [4]. Edge Runtimes: - Cloudflare Workers, which heavily utilizes the Web Crypto API, has implemented crypto.subtle.timingSafeEqual as a non-standard extension to their runtime [7][8][3]. - For environments where crypto.subtle.timingSafeEqual is unavailable (e.g., standard browsers or other edge environments), developers commonly implement a manual timing-safe comparison using a double HMAC verification pattern [1][2][3]. This approach relies on the fact that HMAC verification in Web Crypto is typically implemented in constant time across major browser engines, preventing timing side-channel attacks [1][2]. In summary, crypto.subtle.timingSafeEqual is not a standard web API, but it is available in the Cloudflare Workers runtime as an extension. Node.js provides a similar function, but it resides in the crypto module rather than crypto.subtle [4]. In other environments, manual implementation using cryptographic primitives like HMAC is the standard approach [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the target route and sibling webhook route references.
echo "== files =="
git ls-files | rg '(^|/)route\.ts$' | sed -n '1,120p'

echo "== target outline =="
ast-grep outline src/app/api/webhooks/github/retry/route.ts --view expanded || true

echo "== target route =="
cat -n src/app/api/webhooks/github/retry/route.ts

echo "== sibling route candidates =="
for f in $(git ls-files | rg 'src/app/api/webhooks/github/.*/route\.ts$|src/app/api/webhooks.*route\.ts$'); do
  echo "-- $f --"
  sed -n '1,140p' "$f"
done

echo "== timing-safe / auth headers usage =="
rg -n "timingSafe|timing_safe|GITHUB_WEBHOOK_SECRET|SUPABASE_SERVICE_ROLE_KEY|authorization|Bearer|processDeadLetterQueue|refreshProfile|revalidatePath" src/app/api/webhooks src/app || true

echo "== deterministic auth branch behavior probe =="
python3 - <<'PY'
import os

os.environ.pop("SUPABASE_SERVICE_ROLE_KEY", None)
os.environ.pop("GITHUB_WEBHOOK_SECRET", None)

class Env:
    pass

process = Env()
process.GITHUB_WEBHOOK_SECRET = None
process.SUPABASE_SERVICE_ROLE_KEY = None
auth_header = "Bearer valid"

secret_key = process.SUPABASE_SERVICE_ROLE_KEY or process.GITHUB_WEBHOOK_SECRET
print("secret_key_falsy=", bool(secret_key))
blocked = secret_key and auth_header != f"Bearer {secret_key}"
print("blocked_by_if=", blocked)
print("would_continue_to_endpoint=", not blocked)
PY

Repository: PRODHOSH/ossfolio

Length of output: 12852


Fail closed before queueing retry work.

If neither SUPABASE_SERVICE_ROLE_KEY nor GITHUB_WEBHOOK_SECRET is configured, this route accepts unauthenticated requests and calls processDeadLetterQueue, which can mutate profile data. Add an explicit “secret missing” 503 guard before comparing credentials, and use a timing-safe bearer-token comparison.

🤖 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 `@src/app/api/webhooks/github/retry/route.ts` around lines 6 - 13, Update the
authorization check in the retry route to return a 503 “secret missing” response
when neither SUPABASE_SERVICE_ROLE_KEY nor GITHUB_WEBHOOK_SECRET is configured,
before comparing credentials or queueing work. Then compare the provided bearer
token with the configured secret using a timing-safe comparison, while
preserving the existing 401 response for invalid credentials.

Comment on lines +15 to +21
const { searchParams } = new URL(request.url);
const batchSizeParam = searchParams.get("batchSize");
const batchSize = batchSizeParam ? parseInt(batchSizeParam, 10) : 10;

const result = await processDeadLetterQueue(
isNaN(batchSize) ? 10 : batchSize,
);

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.

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

batchSize isn't clamped.

Negative, zero, or very large values pass straight to processDeadLetterQueue's .limit(batchSize) with only a NaN guard. Clamp to a safe range (e.g., 1-100).

♻️ Proposed fix
-  const batchSize = batchSizeParam ? parseInt(batchSizeParam, 10) : 10;
-
-  const result = await processDeadLetterQueue(
-    isNaN(batchSize) ? 10 : batchSize,
-  );
+  const parsed = batchSizeParam ? parseInt(batchSizeParam, 10) : 10;
+  const batchSize = Number.isNaN(parsed) ? 10 : Math.min(Math.max(parsed, 1), 100);
+
+  const result = await processDeadLetterQueue(batchSize);
📝 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
const { searchParams } = new URL(request.url);
const batchSizeParam = searchParams.get("batchSize");
const batchSize = batchSizeParam ? parseInt(batchSizeParam, 10) : 10;
const result = await processDeadLetterQueue(
isNaN(batchSize) ? 10 : batchSize,
);
const { searchParams } = new URL(request.url);
const batchSizeParam = searchParams.get("batchSize");
const parsed = batchSizeParam ? parseInt(batchSizeParam, 10) : 10;
const batchSize = Number.isNaN(parsed) ? 10 : Math.min(Math.max(parsed, 1), 100);
const result = await processDeadLetterQueue(batchSize);
🤖 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 `@src/app/api/webhooks/github/retry/route.ts` around lines 15 - 21, Clamp the
parsed batchSize in the retry route before passing it to processDeadLetterQueue,
treating invalid or missing values as the default and restricting valid values
to the safe 1–100 range. Update the existing batchSize parsing/validation flow
while preserving the current processDeadLetterQueue call.

Comment on lines +26 to 57
/** Constant-time comparison of two ArrayBuffer or ArrayBufferView byte buffers. */
export function timingSafeEqualBuffer(
a: ArrayBuffer | ArrayBufferView,
b: ArrayBuffer | ArrayBufferView,
): boolean {
const subtle = crypto.subtle as unknown as {
timingSafeEqual?: (
_a: ArrayBuffer | ArrayBufferView,
_b: ArrayBuffer | ArrayBufferView,
) => boolean;
};

if (typeof subtle.timingSafeEqual === "function") {
return subtle.timingSafeEqual(a, b);
}

const viewA =
a instanceof Uint8Array
? a
: new Uint8Array("buffer" in a ? a.buffer : a);
const viewB =
b instanceof Uint8Array
? b
: new Uint8Array("buffer" in b ? b.buffer : b);

if (viewA.byteLength !== viewB.byteLength) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i++) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
for (let i = 0; i < viewA.byteLength; i++) {
mismatch |= viewA[i] ^ viewB[i];
}
return mismatch === 0;
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)(route\.ts|.*webhook.*|.*github.*)$' || true

echo "== target snippet =="
sed -n '1,120p' src/app/api/webhooks/github/route.ts 2>/dev/null || true

echo "== timingSafeEqual usage/tests =="
rg -n "timingSafeEqualBuffer|verifySignature|different byte lengths|byteLength|DataView|sliced|sliced" . --glob '!node_modules' --glob '!dist' --glob '!build' || true

echo "== relevant package/config =="
for f in package.json wrangler.toml next.config.js next.config.mjs tsconfig.json vitest.config.*; do
  [ -f "$f" ] && { echo "--- $f"; sed -n '1,220p' "$f"; }
done

Repository: PRODHOSH/ossfolio

Length of output: 11128


🌐 Web query:

Cloudflare workers crypto.subtle.timingSafeEqual ArrayBufferView byteOffset byteLength equal length documentation

💡 Result:

In Cloudflare Workers, crypto.subtle.timingSafeEqual is a non-standard extension to the Web Crypto API designed to perform constant-time comparisons of binary data to prevent timing side-channel attacks [1]. Regarding the parameters: 1. Arguments: The function accepts two inputs, which must be either ArrayBuffer, TypedArray, or DataView instances [1][2]. 2. Length Requirement: Both input buffers must have the same byte length. If the lengths differ, the function will throw an exception [3][4][2]. 3. Byte-level Comparison: The function performs a constant-time comparison of the underlying bytes of the provided buffers [2][5]. 4. Security Note: While the function itself is designed for constant-time execution, using it does not guarantee that the surrounding code is timing-safe [3][4][6]. Crucially, you must avoid returning early based on length differences, as this creates a timing leak that reveals secret lengths [7][4]. Recommended Pattern: To perform a secure comparison, always compare the input length against your expected secret length without using early returns, and use a dummy comparison when lengths do not match to ensure the execution path remains consistent [3][4]. Example implementation: const userValue = encoder.encode(providedToken); const secretValue = encoder.encode(storedSecret); // Use a constant-time comparison without leaking the length const lengthsMatch = userValue.byteLength === secretValue.byteLength; const isEqual = lengthsMatch? crypto.subtle.timingSafeEqual(userValue, secretValue):!crypto.subtle.timingSafeEqual(userValue, userValue); // Dummy comparison if (!isEqual) { throw new Error("Unauthorized"); }

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== github-webhook tests =="
sed -n '1,130p' src/lib/__tests__/github-webhook.test.ts

echo "== runtime probes for current helper behavior =="
node - <<'JS'
const timingSafeEqualBuffer = (a, b) => {
  const subtle = crypto.subtle ?? {};
  if (typeof subtle.timingSafeEqual === "function") {
    return subtle.timingSafeEqual(a, b);
  }
  const viewA =
    a instanceof Uint8Array ? a : new Uint8Array("buffer" in a ? a.buffer : a);
  const viewB =
    b instanceof Uint8Array ? b : new Uint8Array("buffer" in b ? b.buffer : b);
  if (viewA.byteLength !== viewB.byteLength) return false;
  let mismatch = 0;
  for (let i = 0; i < viewA.byteLength; i++) mismatch |= viewA[i] ^ viewB[i];
  return mismatch === 0;
};

for (const name of ["NodeCrypto.timingSafeEqual present", "NodeCrypto.timingSafeEqual missing"]) {
  console.log("--", name);
  const original = globalThis.crypto?.subtle?.timingSafeEqual;
  if (name === "NodeCrypto.timingSafeEqual missing" && original) {
    globalThis.crypto.subtle.timingSafeEqual = undefined;
  } else if (name === "NodeCrypto.timingSafeEqual present" && !original) {
    globalThis.crypto = {
      subtle: {
        timingSafeEqual: (a, b) =>
          new Uint8Array(a.buffer || a.data, a.byteOffset || a.offset, a.byteLength || a.length).length ===
          new Uint8Array(b.buffer || b.data, b.byteOffset || b.length).length === 
          0,
      },
    };
  }
  const inputs = [
    ["same Uint8Array values"],
    ["sliced Dataview equal"],
    ["different ArrayBufferView views same backing"],
    ["different byte lengths"],
  ];
  for (const input of inputs) {
    let result;
    try {
      if (input[0] === "sliced Dataview equal") {
        const backing = new ArrayBuffer(12);
        const a = new DataView(backing, 0, 4);
        new Uint8Array(backing).set([1,2,3,4], 0);
        const b = new Uint8Array(backing, 0, 4);
        result = "returns " + timingSafeEqualBuffer(a, b);
      } else {
        result = "no-op";
      }
    } catch (e) {
      result = "throws " + e.name + ": " + e.message;
    }
    console.log(input[0], "=>", result);
  }
  if (name === "NodeCrypto.timingSafeEqual missing" && original) {
    globalThis.crypto.subtle.timingSafeEqual = original;
  }
}
JS

Repository: PRODHOSH/ossfolio

Length of output: 4865


🌐 Web query:

Bun crypto.timingSafeEqual ArrayBuffer ArrayBufferView byteLength throw different length documentation

💡 Result:

In Bun, the crypto.timingSafeEqual function requires the two provided ArrayBuffer or ArrayBufferView instances to have the same byte length [1]. If the provided arguments have different byte lengths, the function will throw an error [1]. This behavior aligns with the standard Node.js crypto.timingSafeEqual implementation [2][3]. Because the function is designed to prevent timing attacks when comparing cryptographic signatures or hashes—which typically have a fixed, expected length—it does not support comparing buffers of different sizes [3]. If you need to compare buffers that may have different lengths, you should perform a manual length check first and return false if they do not match [3][4]. This pattern is considered secure because a length comparison is not sensitive to the content of the buffers and does not leak information that would aid a timing attack [3]. Example of safe usage: if (bufferA.byteLength!== bufferB.byteLength) { return false; } return crypto.timingSafeEqual(bufferA, bufferB);

Citations:


Normalize views before the native timingSafeEqual call.

timingSafeEqualBuffer is exposed for ArrayBuffer | ArrayBufferView, but the current fallback normalizes non-Uint8Array views as if they span the whole backing buffer (ignoring byteOffset/byteLength), so a sliced DataView/typed view can be compared incorrectly. Call the native API only after normalizing to a byte-addressed Uint8Array, and call it with same-length buffers to avoid workerd/node timingSafeEqual length-related failures in production.

🤖 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 `@src/app/api/webhooks/github/route.ts` around lines 26 - 57, Update
timingSafeEqualBuffer to normalize both inputs into byte-addressed Uint8Array
views that preserve each source view’s byteOffset and byteLength before any
native timingSafeEqual call. Validate equal byte lengths first, then invoke the
native API with the normalized views; retain the constant-time manual comparison
fallback for environments without the native method.

Comment on lines 136 to +145
try {
await refreshProfile(username);
const res = await refreshProfile(username);
if (res.status === "error" || res.status === "rate_limited") {
await enqueueDeadLetterPayload({
eventType: event ?? "push",
username,
payload: parsedPayload ?? { owner },
errorReason: `refresh_status_${res.status}`,
});
}

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.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
fd -a 'route\.ts$' . | rg 'src/app/api/webhooks/github/route\.ts|refresh-profile' || true

echo "== route excerpt =="
sed -n '1,190p' src/app/api/webhooks/github/route.ts

echo "== refresh profile files =="
fd -a -i 'refresh.*profile' src | sed 's#^\./##'
for f in $(fd -i 'refresh.*profile|refresh-profile' src); do
  echo "--- $f ---"
  wc -l "$f"
  ast-grep outline "$f" --view compact || true
done

Repository: PRODHOSH/ossfolio

Length of output: 5517


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== refresh-profile.ts =="
sed -n '1,160p' src/lib/refresh-profile.ts

echo "== dead letter related files =="
fd -a 'webhook-dead-letter|dead.*letter|queue|dead' src | sed 's#^\./##'

echo "== dead-letter module excerpts =="
for f in $(fd -i 'webhook-dead-letter|dead.*letter|queue|dead' src); do
  echo "--- $f ---"
  wc -l "$f"
  sed -n '1,220p' "$f"
done

echo "== usages of RefreshResult/status/rate_limited =="
rg -n "RefreshResult|refresh_profile|refreshProfile|rate_limited|advisory|lock|sleep|retry" src

Repository: PRODHOSH/ossfolio

Length of output: 39086


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== refreshProfile PGRST116 handling =="
sed -n '37,86p' src/lib/refresh-profile.ts

echo "== refreshProfile RPC lock handling =="
sed -n '24,42p' src/lib/refresh-profile.ts

echo "== behavioral data from refreshProfile source =="
python3 - <<'PY'
from pathlib import Path
text = Path("src/lib/refresh-profile.ts").read_text()
print("- lock false result:", 'lockAcquired === false' in text and 'status: "rate_limited"' in text[text.find('lockAcquired === false'):text.find('lockAcquired === false')+500])
print("- RPC catch branch:", "try {" in text and "catch (err)" in text and text[text.find("catch (err)")-5:text.find("catch (err)")+120].strip())
print("- RPC catch returns error:", "catch (err)" in text and "return { status: \"error\" }" in text[text.find("catch (err)", text.find("catch (err)")):])
print("- PGRST116 update branch:", "PGRST116" in text and 'status: "rate_limited"' in text[text.find("PGRST116"):text.find("PGRST116")+500])
print("- PGRST116 non-error branch:", "PGRST116" in text and "else {" in text[text.find("PGRST116"):text.find("PGRST116")+400])
print("- update error branch:", 'return { status: "error" }' in text[text.find("select(\"username\").single()"):text.find("select(\"username\").single()")+800])
PY

Repository: PRODHOSH/ossfolio

Length of output: 2719


Separate intended rate-limiting from retryable refresh failures.

refreshProfile() returns the same "rate_limited" status for both the 10-minute update window and the advisory-lock/refresh-lock contention paths, while both webhook and dead-letter retry logic enqueue these identical statuses. Split RateLimited into a distinct non-retryable Throttled variant and enqueue only the lock-contention/error cases so rapid same-user pushes don’t fill the retry queue with intentionally skipped refreshes.

🤖 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 `@src/app/api/webhooks/github/route.ts` around lines 136 - 145, Update
refreshProfile status handling to distinguish intentional 10-minute throttling
from retryable lock-contention or refresh failures by introducing a
non-retryable Throttled variant separate from RateLimited. In the webhook flow
around refreshProfile and enqueueDeadLetterPayload, enqueue only retryable
error/lock-contention outcomes, excluding Throttled, and update related
webhook/dead-letter retry checks to preserve this distinction.

Comment on lines +139 to +144
await enqueueDeadLetterPayload({
eventType: event ?? "push",
username,
payload: parsedPayload ?? { owner },
errorReason: `refresh_status_${res.status}`,
});

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Full raw webhook payload (including PII) is persisted to the dead-letter table.

parsedPayload = JSON.parse(body) as PushPayload (line 121) is a compile-time-only type assertion; at runtime parsedPayload still holds the entire GitHub push payload, not just repository.owner. Passing it straight through as payload: parsedPayload ?? { owner } means enqueueDeadLetterPayload stores the full event — including committer/author/pusher emails and names from commits[]/pusher — as JSON in Postgres, well beyond what the narrow PushPayload type suggests is retained. This is a PII-retention/compliance risk for a queue table whose purpose is only to retry a username refresh.

Consider persisting only the minimal fields actually needed to retry (e.g. { owner, ref: parsedPayload.ref }) rather than the full payload.

Also applies to: 151-156

🤖 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 `@src/app/api/webhooks/github/route.ts` around lines 139 - 144, Limit the
dead-letter payload in the webhook handler’s enqueueDeadLetterPayload calls to
only fields required for retry, such as owner and parsedPayload.ref, instead of
passing the full parsedPayload containing PII. Apply this change to both the
refresh-status failure path and the second enqueue call around the referenced
lines, while preserving the existing owner fallback.

Comment on lines +1 to +2
import { supabaseAdmin } from "@/lib/supabase";
import { refreshProfile } from "@/lib/refresh-profile";

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add server-only guard.

This module builds/uses supabaseAdmin() (service-role client). Based on learnings, server-side modules under src/lib that read SUPABASE_SERVICE_ROLE_KEY via supabaseAdmin() should import server-only at the top as a build-time guard against accidental client bundling.

🛡️ Proposed fix
+import "server-only";
 import { supabaseAdmin } from "`@/lib/supabase`";
 import { refreshProfile } from "`@/lib/refresh-profile`";
📝 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
import { supabaseAdmin } from "@/lib/supabase";
import { refreshProfile } from "@/lib/refresh-profile";
import "server-only";
import { supabaseAdmin } from "`@/lib/supabase`";
import { refreshProfile } from "`@/lib/refresh-profile`";
🧰 Tools
🪛 GitHub Actions: Prettier Code Formatting / 0_prettier.txt

[error] 1-1: prettier --check . reported formatting issues: Code style issues found in 192 files. Run Prettier with --write to fix.

🪛 GitHub Actions: Prettier Code Formatting / prettier

[warning] 1-1: Prettier --check reported formatting issues in 192 files. Run 'npx prettier --write .' to fix.


[error] 1-1: Command 'npx prettier --check .' failed with exit code 1 due to code style issues.

🤖 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 `@src/lib/webhook-dead-letter.ts` around lines 1 - 2, Add the `server-only`
import at the top of the `webhook-dead-letter` module before its existing
imports, preserving the current `supabaseAdmin` and `refreshProfile` behavior
while preventing accidental client bundling.

Source: Learnings

Comment on lines +44 to +58
const { data, error } = await adminClient
.from("webhook_dead_letter_queue")
.insert({
event_type: input.eventType,
username: input.username,
payload: input.payload ?? {},
error_reason: input.errorReason ?? "transient_write_failure",
status: "pending",
retry_count: 0,
max_retries: 5,
received_at: now,
next_retry_at: now,
created_at: now,
updated_at: now,
})

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Full raw webhook payload persisted unredacted (mirrors the schema-level concern).

input.payload is inserted verbatim; combined with the webhook_dead_letter_queue.payload jsonb column having no retention limit (see migration file), this indefinitely persists potentially PII-bearing GitHub payload data.

🤖 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 `@src/lib/webhook-dead-letter.ts` around lines 44 - 58, Sanitize and minimize
the payload before the insert in the webhook dead-letter write flow, rather than
persisting input.payload verbatim. Update the code around the
webhook_dead_letter_queue insert to remove or redact sensitive and unnecessary
GitHub fields while preserving the data required for retry and diagnosis, and
ensure the stored payload remains bounded for indefinite retention.

Comment on lines +91 to +116
const { data: items, error: fetchError } = await adminClient
.from("webhook_dead_letter_queue")
.select("*")
.eq("status", "pending")
.lte("next_retry_at", nowIso)
.order("next_retry_at", { ascending: true })
.limit(batchSize);

if (fetchError || !items || items.length === 0) {
return result;
}

for (const item of items as WebhookDeadLetterRow[]) {
result.processed++;
const currentNow = new Date().toISOString();

// Lock row to processing status
await adminClient
.from("webhook_dead_letter_queue")
.update({
status: "processing",
last_retry_at: currentNow,
updated_at: currentNow,
})
.eq("id", item.id);

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Row-claiming has a TOCTOU race across concurrent invocations.

The claiming update (108-115) filters only .eq("id", item.id), with no .eq("status", "pending") guard. Two overlapping processDeadLetterQueue runs (e.g., overlapping cron trigger + manual /retry POST) can both SELECT the same due rows before either marks them processing, causing duplicate refreshProfile calls and racing final-state writes on the same row (a later "retry_scheduled" write could clobber an earlier "completed" write, or vice versa).

🔒 Proposed fix: atomic claim
-      await adminClient
-        .from("webhook_dead_letter_queue")
-        .update({
-          status: "processing",
-          last_retry_at: currentNow,
-          updated_at: currentNow,
-        })
-        .eq("id", item.id);
+      const { data: claimed } = await adminClient
+        .from("webhook_dead_letter_queue")
+        .update({
+          status: "processing",
+          last_retry_at: currentNow,
+          updated_at: currentNow,
+        })
+        .eq("id", item.id)
+        .eq("status", "pending")
+        .select("id")
+        .maybeSingle();
+      if (!claimed) continue; // already claimed by another run
📝 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
const { data: items, error: fetchError } = await adminClient
.from("webhook_dead_letter_queue")
.select("*")
.eq("status", "pending")
.lte("next_retry_at", nowIso)
.order("next_retry_at", { ascending: true })
.limit(batchSize);
if (fetchError || !items || items.length === 0) {
return result;
}
for (const item of items as WebhookDeadLetterRow[]) {
result.processed++;
const currentNow = new Date().toISOString();
// Lock row to processing status
await adminClient
.from("webhook_dead_letter_queue")
.update({
status: "processing",
last_retry_at: currentNow,
updated_at: currentNow,
})
.eq("id", item.id);
const { data: items, error: fetchError } = await adminClient
.from("webhook_dead_letter_queue")
.select("*")
.eq("status", "pending")
.lte("next_retry_at", nowIso)
.order("next_retry_at", { ascending: true })
.limit(batchSize);
if (fetchError || !items || items.length === 0) {
return result;
}
for (const item of items as WebhookDeadLetterRow[]) {
result.processed++;
const currentNow = new Date().toISOString();
// Lock row to processing status
const { data: claimed } = await adminClient
.from("webhook_dead_letter_queue")
.update({
status: "processing",
last_retry_at: currentNow,
updated_at: currentNow,
})
.eq("id", item.id)
.eq("status", "pending")
.select("id")
.maybeSingle();
if (!claimed) continue; // already claimed by another run
🤖 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 `@src/lib/webhook-dead-letter.ts` around lines 91 - 116, Update the
row-claiming update in processDeadLetterQueue to atomically claim only rows
still in pending status by adding a status predicate alongside the existing id
filter. Ensure subsequent processing continues only when that claim succeeds, so
concurrent invocations skip rows already claimed by another run.

Comment on lines +103 to +164
for (const item of items as WebhookDeadLetterRow[]) {
result.processed++;
const currentNow = new Date().toISOString();

// Lock row to processing status
await adminClient
.from("webhook_dead_letter_queue")
.update({
status: "processing",
last_retry_at: currentNow,
updated_at: currentNow,
})
.eq("id", item.id);

const refreshRes = await refreshProfile(item.username);

if (refreshRes.status === "refreshed") {
const finishNow = new Date().toISOString();
await adminClient
.from("webhook_dead_letter_queue")
.update({
status: "completed",
updated_at: finishNow,
})
.eq("id", item.id);
result.succeeded++;
} else {
const nextRetryCount = item.retry_count + 1;
const finishNow = new Date().toISOString();

if (nextRetryCount >= item.max_retries) {
await adminClient
.from("webhook_dead_letter_queue")
.update({
status: "failed",
retry_count: nextRetryCount,
error_reason: `max_retries_exceeded: ${refreshRes.status}`,
updated_at: finishNow,
})
.eq("id", item.id);
result.failed++;
} else {
// Exponential backoff: 5m, 10m, 20m, 40m...
const backoffMinutes = Math.pow(2, nextRetryCount - 1) * 5;
const nextRetryDate = new Date(
Date.now() + backoffMinutes * 60 * 1000,
).toISOString();

await adminClient
.from("webhook_dead_letter_queue")
.update({
status: "pending",
retry_count: nextRetryCount,
next_retry_at: nextRetryDate,
error_reason: `retry_scheduled: ${refreshRes.status}`,
updated_at: finishNow,
})
.eq("id", item.id);
result.retried++;
}
}
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unhandled per-item exceptions leave rows stuck at processing; update errors aren't checked.

The whole loop body runs under the outer try/catch (87-171) but has no per-item try/catch. If refreshProfile throws (its own DB write path isn't wrapped in try/catch), the row was already flipped to processing and the exception unwinds past the finalize step — that row is now permanently excluded from the status='pending' retry query with no reset/timeout. Separately, none of the four .update() calls in this loop (108-115, 121-127, 134-142, 151-160) inspect the returned { error }, so a failed write is silently counted as success/retry in result.

🔧 Proposed fix: per-item isolation + error checks
     for (const item of items as WebhookDeadLetterRow[]) {
       result.processed++;
       const currentNow = new Date().toISOString();
-
-      // Lock row to processing status
-      await adminClient...
-
-      const refreshRes = await refreshProfile(item.username);
-
-      if (refreshRes.status === "refreshed") {
-        ...
-      } else {
-        ...
-      }
+      try {
+        // ... claim, refresh, and finalize as before, checking `error` on each update
+      } catch (itemErr) {
+        console.error("[webhook-dead-letter] item processing failed", { id: item.id, itemErr });
+        await adminClient
+          .from("webhook_dead_letter_queue")
+          .update({ status: "pending", updated_at: new Date().toISOString() })
+          .eq("id", item.id);
+        result.failed++;
+      }
     }
🤖 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 `@src/lib/webhook-dead-letter.ts` around lines 103 - 164, Update the loop
handling in the dead-letter processing function to isolate each item with a
per-item try/catch, ensuring exceptions from refreshProfile or status updates do
not abort the batch and leave rows in processing. Check the returned error from
every webhook_dead_letter_queue update, and route any failure through the item’s
failure/recovery handling before updating result counters; preserve the existing
completed, failed, and pending retry transitions for successful writes.

Comment on lines +5 to +19
create table if not exists public.webhook_dead_letter_queue (
id uuid primary key default gen_random_uuid(),
event_type text not null,
username text not null,
payload jsonb not null default '{}'::jsonb,
error_reason text,
status text not null default 'pending' check (status in ('pending', 'processing', 'completed', 'failed')),
retry_count integer not null default 0,
max_retries integer not null default 5,
received_at timestamptz not null default now(),
last_retry_at timestamptz,
next_retry_at timestamptz not null default now(),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Raw webhook payload persisted indefinitely with no retention/redaction policy.

payload jsonb stores the entire GitHub webhook body (push events include committer/author emails and other PII) with no TTL or cleanup path for completed/failed rows. RLS is correctly locked to service-role-only access, but that only limits who can read it, not how long it's retained. Consider either redacting sensitive fields before insert or adding a scheduled cleanup for terminal-status rows past a retention window.

🧰 Tools
🪛 Squawk (2.59.0)

[warning] 12-12: Using 32-bit integer fields can result in hitting the max int limit. Use 64-bit integer values instead to prevent hitting this limit.

(prefer-bigint-over-int)


[warning] 13-13: Using 32-bit integer fields can result in hitting the max int limit. Use 64-bit integer values instead to prevent hitting this limit.

(prefer-bigint-over-int)

🤖 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 `@supabase/migrations/20260726000001_add_webhook_dead_letters.sql` around lines
5 - 19, The webhook_dead_letter_queue schema retains complete payloads
indefinitely; add a retention mechanism for terminal completed/failed rows, such
as an indexed scheduled cleanup with a defined retention window, and redact
sensitive webhook fields before persistence where applicable. Keep pending and
processing retry data available and preserve service-role-only access.

@PRODHOSH
PRODHOSH self-requested a review July 26, 2026 15:29
@PRODHOSH PRODHOSH added ELUSOC ELUSOC project submission completed VETERAN Advanced — 50 pts labels Jul 26, 2026
@PRODHOSH
PRODHOSH merged commit 6a9dd7b into PRODHOSH:main Jul 26, 2026
9 of 14 checks passed
@github-actions

Copy link
Copy Markdown
Contributor

🎉 Your PR just got merged, @Aditya8369 — thank you for contributing to OSSfolio!

Your work is now part of the project. Here's what to do next:

  • ⭐ If you haven't already, consider giving the repo a star — it helps us grow.
  • 📢 Share your contribution on LinkedIn, Twitter, or wherever you hang out. You shipped open source!
  • 🔍 Browse other open issues if you want to keep contributing.

We really appreciate you taking the time. See you in the next PR! 🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend API / Database / Server completed ELUSOC ELUSOC project submission frontend Related to UI / Next.js VETERAN Advanced — 50 pts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEAT] Implement constant-time HMAC-SHA256 webhook verification and dead-letter retry persistence for GitHub events

2 participants