Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions convex/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,12 +651,27 @@ export const emergencyReconnectAccount = internalMutation({
});

export const getConnectSessionByState = internalQuery({
args: { state: v.string() },
args: { state: v.string(), redirectUri: v.string() },
handler: async (ctx, args) => {
return await ctx.db
const userId = await requireCurrentUserId(ctx);
const workspace = await requireCurrentWorkspace(ctx);
const session = await ctx.db
.query("instagramConnectSessions")
.withIndex("by_state", (q) => q.eq("state", args.state))
.unique();

if (
session === null ||
session.createdByUserId !== userId ||
session.workspaceId !== workspace._id ||
session.status !== "pending" ||
session.expiresAt < Date.now() ||
session.redirectUri !== args.redirectUri
) {
return null;
}

return session;
},
});

Expand Down Expand Up @@ -698,6 +713,9 @@ export const upsertConnectedAccount = internalMutation({
if (session === null) {
throw new Error("Connection session not found.");
}
if (session.status !== "pending" || session.expiresAt < Date.now()) {
throw new Error("Connection session is not pending.");
}

const existingAccount = await getWorkspaceInstagramAccountByExternalId(
ctx,
Expand Down
12 changes: 12 additions & 0 deletions convex/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";
import { auth } from "./auth";
import { requireMetaEnv } from "./meta/config";
import { verifyMetaWebhookSignature } from "./meta/webhookSignature";

const http = httpRouter();

Expand Down Expand Up @@ -30,7 +31,18 @@ http.route({
path: "/meta/webhooks",
method: "POST",
handler: httpAction(async (ctx, request) => {
const { appSecret } = requireMetaEnv();
const body = await request.text();
const signatureHeader = request.headers.get("x-hub-signature-256");

const signatureValid = await verifyMetaWebhookSignature({
body,
appSecret,
signatureHeader,
});
if (!signatureValid) {
return new Response("Invalid webhook signature", { status: 403 });
}

// Route based on webhook object type
let isCommentWebhook = false;
Expand Down
8 changes: 6 additions & 2 deletions convex/meta/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ export function requireMetaEnv() {
}

export function requireSiteUrl() {
const siteUrl = process.env.SITE_URL;
const siteUrl = process.env.SITE_URL?.trim();
if (!siteUrl) {
throw new Error("Missing SITE_URL. Configure SITE_URL for tracked links.");
}
const parsed = new URL(siteUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("SITE_URL must use http or https.");
}

return siteUrl;
return parsed.origin;
}
3 changes: 2 additions & 1 deletion convex/meta/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,11 @@ export const exchangeCodeForAccount = action({
internal.accounts.getConnectSessionByState,
{
state: args.state,
redirectUri: args.redirectUri,
},
);

if (session === null || session.expiresAt < Date.now()) {
if (session === null) {
throw new Error(
"This Instagram connection session is invalid or expired.",
);
Expand Down
74 changes: 74 additions & 0 deletions convex/meta/webhookSignature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const META_WEBHOOK_SIGNATURE_PREFIX = "sha256=";
const HEX_SHA256_LENGTH = 64;

function toHex(bytes: Uint8Array) {
return Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}

function normalizeMetaWebhookSignature(signatureHeader: string | null) {
const signature = signatureHeader?.trim() ?? "";
if (!signature.startsWith(META_WEBHOOK_SIGNATURE_PREFIX)) {
return null;
}

const digest = signature.slice(META_WEBHOOK_SIGNATURE_PREFIX.length);
if (digest.length !== HEX_SHA256_LENGTH || !/^[0-9a-fA-F]+$/.test(digest)) {
return null;
}

return digest.toLowerCase();
}

function timingSafeHexEqual(left: string, right: string) {
let diff = left.length ^ right.length;
const length = Math.max(left.length, right.length);

for (let index = 0; index < length; index += 1) {
const leftCode = index < left.length ? left.charCodeAt(index) : 0;
const rightCode = index < right.length ? right.charCodeAt(index) : 0;
diff |= leftCode ^ rightCode;
}

return diff === 0;
}

export async function computeMetaWebhookSignature(args: {
body: string;
appSecret: string;
}) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(args.appSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign(
"HMAC",
key,
encoder.encode(args.body),
);

return toHex(new Uint8Array(signature));
}

export async function verifyMetaWebhookSignature(args: {
body: string;
appSecret: string;
signatureHeader: string | null;
}) {
const providedSignature = normalizeMetaWebhookSignature(args.signatureHeader);
if (providedSignature === null) {
return false;
}

const expectedSignature = await computeMetaWebhookSignature({
body: args.body,
appSecret: args.appSecret,
});

return timingSafeHexEqual(providedSignature, expectedSignature);
}
12 changes: 10 additions & 2 deletions lib/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,17 @@ export function isMetaConfigured() {
}

export function getSiteUrl(request: Request) {
const siteUrl = process.env.SITE_URL;
const siteUrl = process.env.SITE_URL?.trim();
if (siteUrl) {
return siteUrl;
const parsed = new URL(siteUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("SITE_URL must use http or https.");
}
return parsed.origin;
}

if (process.env.NODE_ENV !== "development" && process.env.NODE_ENV !== "test") {
throw new Error("SITE_URL is required outside local development.");
}

return new URL(request.url).origin;
Expand Down
Loading
Loading