-
Notifications
You must be signed in to change notification settings - Fork 0
Automated Test: oauth-security-enhanced #363
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,93 @@ | ||||||||||||||||||||||||||||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||||||||||||||||||||||||||||
| import z from "zod"; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; | ||||||||||||||||||||||||||||
| import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; | ||||||||||||||||||||||||||||
| import { symmetricDecrypt } from "@calcom/lib/crypto"; | ||||||||||||||||||||||||||||
| import prisma from "@calcom/prisma"; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const appCredentialWebhookRequestBodySchema = z.object({ | ||||||||||||||||||||||||||||
| // UserId of the cal.com user | ||||||||||||||||||||||||||||
| userId: z.number().int(), | ||||||||||||||||||||||||||||
| appSlug: z.string(), | ||||||||||||||||||||||||||||
| // Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY | ||||||||||||||||||||||||||||
| keys: z.string(), | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| /** */ | ||||||||||||||||||||||||||||
| export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||||||||||||||||||||||||||
| // Check that credential sharing is enabled | ||||||||||||||||||||||||||||
| if (!APP_CREDENTIAL_SHARING_ENABLED) { | ||||||||||||||||||||||||||||
| return res.status(403).json({ message: "Credential sharing is not enabled" }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Check that the webhook secret matches | ||||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||||
| req.headers[process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"] !== | ||||||||||||||||||||||||||||
| process.env.CALCOM_WEBHOOK_SECRET | ||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||
| return res.status(403).json({ message: "Invalid webhook secret" }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body); | ||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unhandled
♻️ Proposed fix using safeParse- const reqBody = appCredentialWebhookRequestBodySchema.parse(req.body);
+ const parsed = appCredentialWebhookRequestBodySchema.safeParse(req.body);
+ if (!parsed.success) {
+ return res.status(400).json({ message: "Invalid request body", error: parsed.error.flatten() });
+ }
+ const reqBody = parsed.data;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Check that the user exists | ||||||||||||||||||||||||||||
| const user = await prisma.user.findUnique({ where: { id: reqBody.userId } }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!user) { | ||||||||||||||||||||||||||||
| return res.status(404).json({ message: "User not found" }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| const app = await prisma.app.findUnique({ | ||||||||||||||||||||||||||||
| where: { slug: reqBody.appSlug }, | ||||||||||||||||||||||||||||
| select: { slug: true }, | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!app) { | ||||||||||||||||||||||||||||
| return res.status(404).json({ message: "App not found" }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Search for the app's slug and type | ||||||||||||||||||||||||||||
| const appMetadata = appStoreMetadata[app.slug as keyof typeof appStoreMetadata]; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (!appMetadata) { | ||||||||||||||||||||||||||||
| return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Decrypt the keys | ||||||||||||||||||||||||||||
| const keys = JSON.parse( | ||||||||||||||||||||||||||||
| symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "") | ||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||
|
Comment on lines
+56
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Decryption and JSON parse are unguarded — any failure becomes a 500. If ♻️ Proposed fix // Decrypt the keys
- const keys = JSON.parse(
- symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
- );
+ let keys;
+ try {
+ keys = JSON.parse(
+ symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
+ );
+ } catch (e) {
+ return res.status(400).json({ message: "Failed to decrypt or parse credential keys" });
+ }📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| // Can't use prisma upsert as we don't know the id of the credential | ||||||||||||||||||||||||||||
| const appCredential = await prisma.credential.findFirst({ | ||||||||||||||||||||||||||||
| where: { | ||||||||||||||||||||||||||||
| userId: reqBody.userId, | ||||||||||||||||||||||||||||
| appId: appMetadata.slug, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| select: { | ||||||||||||||||||||||||||||
| id: true, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| if (appCredential) { | ||||||||||||||||||||||||||||
| await prisma.credential.update({ | ||||||||||||||||||||||||||||
| where: { | ||||||||||||||||||||||||||||
| id: appCredential.id, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||
| key: keys, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` }); | ||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||
| await prisma.credential.create({ | ||||||||||||||||||||||||||||
| data: { | ||||||||||||||||||||||||||||
| key: keys, | ||||||||||||||||||||||||||||
| userId: reqBody.userId, | ||||||||||||||||||||||||||||
| appId: appMetadata.slug, | ||||||||||||||||||||||||||||
| type: appMetadata.type, | ||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||
| return res.status(200).json({ message: `Credentials created for userId: ${reqBody.userId}` }); | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,32 @@ | ||||||||||||||||||||||||||||||||||||||
| import { z } from "zod"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const minimumTokenResponseSchema = z.object({ | ||||||||||||||||||||||||||||||||||||||
| access_token: z.string(), | ||||||||||||||||||||||||||||||||||||||
| // Assume that any property with a number is the expiry | ||||||||||||||||||||||||||||||||||||||
| [z.string().toString()]: z.number(), | ||||||||||||||||||||||||||||||||||||||
| // Allow other properties in the token response | ||||||||||||||||||||||||||||||||||||||
| [z.string().optional().toString()]: z.unknown().optional(), | ||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+5
to
+11
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Test Zod behavior with computed property keys and key stripping
cat <<'EOF' > /tmp/test_zod.js
const { z } = require("zod");
console.log("=== Testing z.string().toString() ===");
console.log("z.string().toString() =>", z.string().toString());
console.log("z.string().optional().toString() =>", z.string().optional().toString());
console.log("\n=== Testing z.object with computed keys ===");
const schema = z.object({
access_token: z.string(),
[z.string().toString()]: z.number(),
[z.string().optional().toString()]: z.unknown().optional(),
});
console.log("Schema keys:", Object.keys(schema.shape));
console.log("\n=== Testing safeParse with unknown keys ===");
const input = { access_token: "abc", refresh_token: "def", expires_in: 3600, scope: "read" };
const result = schema.safeParse(input);
console.log("Parse success:", result.success);
if (result.success) {
console.log("Parsed data keys:", Object.keys(result.data));
console.log("Parsed data:", JSON.stringify(result.data, null, 2));
} else {
console.log("Error:", result.error.message);
}
console.log("\n=== Testing with .passthrough() ===");
const passthroughSchema = z.object({
access_token: z.string(),
}).passthrough();
const result2 = passthroughSchema.safeParse(input);
console.log("Parse success:", result2.success);
if (result2.success) {
console.log("Parsed data keys:", Object.keys(result2.data));
console.log("Parsed data:", JSON.stringify(result2.data, null, 2));
}
EOF
cd /tmp && npm init -y --silent 2>/dev/null && npm install zod --silent 2>/dev/null && node test_zod.jsRepository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 975 🏁 Script executed: # Check the parseRefreshTokenResponse.ts file for the Google Calendar issue
grep -n "parseRefreshTokenResponse" packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts | head -5
cat -n packages/app-store/_utils/oauth/parseRefreshTokenResponse.ts | head -35Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 1469 🏁 Script executed: # Search for usage of parseRefreshTokenResponse in Google Calendar and other apps
rg "parseRefreshTokenResponse" --type typescript -B 2 -A 5 packages/app-store/ | grep -E "(googlecalendar|zoomvideo|office365calendar)" -A 8 | head -60Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 124 🏁 Script executed: # Search for parseRefreshTokenResponse usage in the codebase
rg "parseRefreshTokenResponse" -B 2 -A 8 packages/app-store/ | head -150Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 9924 🏁 Script executed: # Double-check what the literal computed key string is
cat <<'EOF' > /tmp/check_key.js
const key = "[object Object]";
console.log("Literal key string:", key);
console.log("Key from z.string().toString():", JSON.stringify(key));
// Verify the schema shape
const { z } = require("zod");
const schema = z.object({
access_token: z.string(),
[z.string().toString()]: z.number(),
});
console.log("Schema shape keys:", Object.keys(schema.shape));
EOF
cd /tmp && npm install zod --silent 2>/dev/null && node check_key.jsRepository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 232
To retain all properties while only requiring Proposed fix-const minimumTokenResponseSchema = z.object({
- access_token: z.string(),
- // Assume that any property with a number is the expiry
- [z.string().toString()]: z.number(),
- // Allow other properties in the token response
- [z.string().optional().toString()]: z.unknown().optional(),
-});
+const minimumTokenResponseSchema = z
+ .object({
+ access_token: z.string(),
+ })
+ .passthrough();🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| const parseRefreshTokenResponse = (response: any, schema: z.ZodTypeAny) => { | ||||||||||||||||||||||||||||||||||||||
| let refreshTokenResponse; | ||||||||||||||||||||||||||||||||||||||
| if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT) { | ||||||||||||||||||||||||||||||||||||||
| refreshTokenResponse = minimumTokenResponseSchema.safeParse(response); | ||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||
| refreshTokenResponse = schema.safeParse(response); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (!refreshTokenResponse.success) { | ||||||||||||||||||||||||||||||||||||||
| throw new Error("Invalid refreshed tokens were returned"); | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| if (!refreshTokenResponse.data.refresh_token) { | ||||||||||||||||||||||||||||||||||||||
| refreshTokenResponse.data.refresh_token = "refresh_token"; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| return refreshTokenResponse; | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+21
to
+29
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Return type is The function throws on parse failure (Line 22) but returns the full
Since the function already throws on failure, it should return just Proposed fix if (!refreshTokenResponse.success) {
throw new Error("Invalid refreshed tokens were returned");
}
if (!refreshTokenResponse.data.refresh_token) {
refreshTokenResponse.data.refresh_token = "refresh_token";
}
- return refreshTokenResponse;
+ return refreshTokenResponse.data;
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export default parseRefreshTokenResponse; | ||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,22 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants"; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Check that app syncing is enabled and that the credential belongs to a user | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT && userId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Customize the payload based on what your endpoint requires | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // The response should only contain the access token and expiry date | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| method: "POST", | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| body: new URLSearchParams({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| calcomUserId: userId.toString(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| appSlug, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+8
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if CALCOM_WEBHOOK_SECRET is used anywhere for outbound requests
echo "=== Usage of CALCOM_WEBHOOK_SECRET ==="
rg -n "CALCOM_WEBHOOK_SECRET" --type=ts -C3
echo ""
echo "=== Usage of CALCOM_WEBHOOK_HEADER_NAME ==="
rg -n "CALCOM_WEBHOOK_HEADER_NAME" --type=ts -C3Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 1695 🏁 Script executed: # Check .env.example for these variables
echo "=== .env.example definitions ==="
if [ -f .env.example ]; then
grep -n "CALCOM_WEBHOOK_SECRET\|CALCOM_WEBHOOK_HEADER_NAME" .env.example
else
echo ".env.example not found"
fi
echo ""
# Find and check the actual refreshOAuthTokens.ts file
echo "=== Full context of refreshOAuthTokens.ts ==="
find . -name "refreshOAuthTokens.ts" -type fRepository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 309 🏁 Script executed: # Check how CALCOM_CREDENTIAL_SYNC_ENDPOINT is used
echo "=== Usage of CALCOM_CREDENTIAL_SYNC_ENDPOINT ==="
rg -n "CALCOM_CREDENTIAL_SYNC_ENDPOINT" --type=ts -C5Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 2683 🏁 Script executed: cat -n packages/app-store/_utils/oauth/refreshOAuthTokens.tsRepository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 1094 🏁 Script executed: # Check for any documentation or comments about CALCOM_CREDENTIAL_SYNC_ENDPOINT
rg -n "CALCOM_CREDENTIAL_SYNC_ENDPOINT" --type=ts --type=md -B2 -A2Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 1699 🏁 Script executed: # Check how the webhook endpoint at app-credential.ts is structured (see if it's meant to receive this request)
cat -n apps/web/pages/api/webhook/app-credential.ts | head -50Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror Length of output: 2123 Add authentication header to credential sync request. The POST to Proposed fix — include the webhook secret header const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
method: "POST",
+ headers: {
+ [process.env.CALCOM_WEBHOOK_HEADER_NAME || "calcom-webhook-secret"]:
+ process.env.CALCOM_WEBHOOK_SECRET || "",
+ },
body: new URLSearchParams({
calcomUserId: userId.toString(),
appSlug,
}),
});🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return response; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await refreshFunction(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return response; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+3
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Return type divergence between the two code paths may break callers. When Additionally:
Suggested improvements-const refreshOAuthTokens = async (refreshFunction: () => any, appSlug: string, userId: number | null) => {
+const refreshOAuthTokens = async (
+ refreshFunction: () => Promise<Response>,
+ appSlug: string,
+ userId: number | null
+): Promise<Response> => {
// Check that app syncing is enabled and that the credential belongs to a user
if (APP_CREDENTIAL_SHARING_ENABLED && process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT && userId) {
- // Customize the payload based on what your endpoint requires
- // The response should only contain the access token and expiry date
const response = await fetch(process.env.CALCOM_CREDENTIAL_SYNC_ENDPOINT, {
method: "POST",
body: new URLSearchParams({
calcomUserId: userId.toString(),
appSlug,
}),
});
+ if (!response.ok) {
+ throw new Error(`Credential sync endpoint returned ${response.status}`);
+ }
return response;
} else {
- const response = await refreshFunction();
- return response;
+ return refreshFunction();
}
};📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export default refreshOAuthTokens; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing HTTP method restriction — endpoint accepts any method.
This webhook handler should only accept
POST. Without a method guard,GET/DELETE/etc. requests fall through to the body-parsing logic, resulting in confusing 500 errors instead of a clean405 Method Not Allowed.Additionally, the webhook-secret comparison on line 25 uses
!==, which is susceptible to timing attacks. UsetimingSafeEqualfrom Node'scryptomodule instead.🛡️ Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents