Skip to content
Open
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
11 changes: 9 additions & 2 deletions apps/web/app/api/payment/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@

export async function POST(req: NextRequest) {
try {
const body = await req.json();
const rawBody = await req.text();
const body = JSON.parse(rawBody);
const domainName = req.headers.get("domain");

const domain = await getDomain(domainName);
Expand All @@ -33,7 +34,13 @@
return Response.json({ message: "Payment method not found" });
}

if (!(await paymentMethod.verify(body))) {
const headers: Record<string, string | null> = {
"stripe-signature": req.headers.get("stripe-signature"),
"x-razorpay-signature": req.headers.get("x-razorpay-signature"),
"x-signature": req.headers.get("x-signature"),
};

if (!(await paymentMethod.verify(body, rawBody, headers))) {
return Response.json({ message: "Payment not verified" });
}

Expand Down Expand Up @@ -99,10 +106,10 @@
domainId: mongoose.Types.ObjectId,
membershipId: string,
) {
return MembershipModel.findOne<Membership>({
domain: domainId,
membershipId,
});

Check failure

Code scanning / CodeQL

Database query built from user-controlled sources High

This query object depends on a
user-provided value
.
}

async function getPaymentPlan(
Expand Down Expand Up @@ -146,11 +153,11 @@
currencyISOCode: string,
body: any,
) {
const invoice = await InvoiceModel.findOne<Invoice>({
domain: domain._id,
invoiceId,
status: Constants.InvoiceStatus.PENDING,
});

Check failure

Code scanning / CodeQL

Database query built from user-controlled sources High

This query object depends on a
user-provided value
.
if (invoice) {
invoice.paymentProcessorTransactionId =
paymentMethod.getPaymentIdentifier(body);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
52 changes: 35 additions & 17 deletions apps/web/payments-new/lemonsqueezy-payment.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Payment, { InitiateProps } from "./payment";
import { responses } from "../config/strings";
import crypto from "crypto";
import {
Constants,
PaymentPlan,
Expand All @@ -20,7 +21,7 @@
public siteinfo: SiteInfo;
public name: string;
private apiKey: string;
// private webhookSecret: string;
private webhookSecret: string | undefined;

constructor(siteinfo: SiteInfo) {
this.siteinfo = siteinfo;
Expand All @@ -45,7 +46,7 @@
}

this.apiKey = this.siteinfo.lemonsqueezyKey;
// this.webhookSecret = this.siteinfo.lemonsqueezyWebhookSecret;
this.webhookSecret = this.siteinfo.lemonsqueezyWebhookSecret;

return this;
}
Expand Down Expand Up @@ -134,8 +135,38 @@
return store.attributes.currency.toLowerCase();
}

async verify(event: any) {
// if (!this.verifyWebhookSignature(event)) return false;
async verify(
event: any,
rawBody: string,
headers: Record<string, string | null>,
) {
const signature = headers["x-signature"];
if (!signature || !this.webhookSecret) {
return false;
}

const digest = crypto
.createHmac("sha256", this.webhookSecret)
.update(rawBody)
.digest("hex");
const receivedHash = signature.startsWith("sha256=")
? signature.slice(7)
: signature;
try {
const expected = Buffer.from(digest);
const received = Buffer.from(receivedHash);
if (
expected.length !== received.length ||
!crypto.timingSafeEqual(
new Uint8Array(expected),
new Uint8Array(received),
)
) {
return false;
}
} catch {
return false;
}

const eventType = event.meta.event_name;
const attributes = event.data.attributes;
Expand All @@ -159,9 +190,9 @@
private async cancelSubscriptionForAllPaidEMIPlan(event) {
const metadata = this.getMetadata(event);
const membership: InternalMembership | null =
await MembershipModel.findOne({
membershipId: metadata.membershipId,
});

Check failure

Code scanning / CodeQL

Database query built from user-controlled sources High

This query object depends on a
user-provided value
.
if (membership) {
const paymentPlan: PaymentPlan | null =
await PaymentPlanModel.findOne({
Expand All @@ -173,13 +204,13 @@
paymentPlan.type === Constants.PaymentPlanType.EMI
) {
const paidInvoicesCount = await Invoice.countDocuments({
domain: membership.domain,
membershipId: membership.membershipId,
status: Constants.InvoiceStatus.PAID,
membershipSessionId: membership.sessionId,
});
if (paidInvoicesCount >= paymentPlan.emiTotalInstallments!) {
try {

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.
await this.cancel(membership.subscriptionId!);
} catch (err) {
error(`Error cancelling Lemonsqueezy subscription`, {
Expand Down Expand Up @@ -214,10 +245,10 @@

if (!response.ok) {
throw new Error("Failed to cancel subscription");
}

return true;
}

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

The
URL
of this request depends on a
user-provided value
.

getSubscriptionId(event: any): string {
return event.data.attributes.subscription_id;
Expand Down Expand Up @@ -267,17 +298,4 @@
const { data } = await response.json();
return data;
}

// private verifyWebhookSignature(event: any) {
// const signature = event.meta.signature;
// const payload = JSON.stringify(event);

// const hmac = crypto.createHmac("sha256", this.webhookSecret);
// const digest = hmac.update(payload).digest("hex");

// return crypto.timingSafeEqual(
// Buffer.from(signature),
// Buffer.from(digest)
// );
// }
}
6 changes: 5 additions & 1 deletion apps/web/payments-new/payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ interface Metadata {
export default interface Payment {
setup: () => void;
initiate: (obj: InitiateProps) => void;
verify: (event: any) => Promise<boolean>;
verify: (
event: any,
rawBody: string,
headers: Record<string, string | null>,
) => Promise<boolean>;
getPaymentIdentifier: (event: any) => unknown;
getMetadata: (event: any) => Record<string, unknown>;
getName: () => string;
Expand Down
36 changes: 35 additions & 1 deletion apps/web/payments-new/razorpay-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Payment, { InitiateProps } from "./payment";
import { responses } from "@config/strings";
import Razorpay from "razorpay";
import { getUnitAmount } from "./helpers";
import crypto from "crypto";

const {
payment_invalid_settings: paymentInvalidSettings,
Expand All @@ -13,6 +14,7 @@ export default class RazorpayPayment implements Payment {
public siteinfo: SiteInfo;
public name: string;
public razorpay: any;
private webhookSecret: string | undefined;

constructor(siteinfo: SiteInfo) {
this.siteinfo = siteinfo;
Expand All @@ -33,6 +35,8 @@ export default class RazorpayPayment implements Payment {
key_secret: this.siteinfo.razorpaySecret,
});

this.webhookSecret = this.siteinfo.razorpayWebhookSecret;

return this;
}

Expand All @@ -45,10 +49,40 @@ export default class RazorpayPayment implements Payment {
return order.id;
}

async verify(event) {
async verify(
event: any,
rawBody: string,
headers: Record<string, string | null>,
) {
if (!event) {
return false;
}

const signature = headers["x-razorpay-signature"];
if (!signature || !this.webhookSecret) {
return false;
}

const expectedSignature = crypto
.createHmac("sha256", this.webhookSecret)
.update(rawBody)
.digest("hex");
try {
const expected = Buffer.from(expectedSignature);
const received = Buffer.from(signature);
if (
expected.length !== received.length ||
!crypto.timingSafeEqual(
new Uint8Array(expected),
new Uint8Array(received),
)
) {
return false;
}
} catch {
return false;
}

if (
event.event === "order.paid" &&
event.payload.order.entity.notes.membershipId
Expand Down
24 changes: 22 additions & 2 deletions apps/web/payments-new/stripe-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class StripePayment implements Payment {
public siteinfo: SiteInfo;
public name: string;
public stripe: any;
private webhookSecret: string | undefined;

constructor(siteinfo: SiteInfo) {
this.siteinfo = siteinfo;
Expand All @@ -37,6 +38,8 @@ export default class StripePayment implements Payment {
typescript: true,
});

this.webhookSecret = this.siteinfo.stripeWebhookSecret;

return this;
}

Expand Down Expand Up @@ -77,10 +80,27 @@ export default class StripePayment implements Payment {
return this.siteinfo.currencyISOCode!;
}

async verify(event: Stripe.Event) {
if (!event) {
async verify(
_event: any,
rawBody: string,
headers: Record<string, string | null>,
) {
const signature = headers["stripe-signature"];
if (!signature || !this.webhookSecret) {
return false;
}

let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
rawBody,
signature,
this.webhookSecret,
);
} catch {
return false;
}

if (
event.type === "checkout.session.completed" &&
(event.data.object as any).payment_status === "paid"
Expand Down
1 change: 1 addition & 0 deletions packages/orm-models/src/models/site-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const SettingsSchema = new mongoose.Schema<SiteInfo>({
currencyISOCode: { type: String, maxlength: 3 },
paymentMethod: { type: String, enum: Constants.paymentMethods },
stripeKey: { type: String },
stripeWebhookSecret: { type: String },
codeInjectionHead: { type: String },
codeInjectionBody: { type: String },
stripeSecret: { type: String },
Expand Down
Loading