Skip to content

Commit e77e278

Browse files
github-actions[bot]claudfuenclaude
authored
fix(billing): harden server actions against open redirect and runId abuse (#2217)
- Remove returnUrl/returnBaseUrl parameters from subscribeToPentestPlan and createBillingPortalSession; URLs are now computed server-side from env vars + request host, preventing open-redirect phishing via direct server action invocation - Validate runId in checkAndChargePentestBilling against the DB to ensure the run exists and belongs to the org, preventing repeated overage charges via arbitrary fabricated runId strings - Remove duplicate PageLayout/PageHeader from billing page (layout already provides them for all settings routes) Co-authored-by: Claudio Fuentes <claudio@trycomp.ai> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9ec0449 commit e77e278

2 files changed

Lines changed: 24 additions & 20 deletions

File tree

  • apps/app/src/app/(app)/[orgId]

apps/app/src/app/(app)/[orgId]/security/penetration-tests/actions/billing.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,19 @@ async function requireOrgMember(orgId: string): Promise<void> {
1616
}
1717
}
1818

19+
async function getOrgBillingUrl(orgId: string): Promise<string> {
20+
const requestHeaders = await headers();
21+
const host = requestHeaders.get('host') ?? '';
22+
const proto = host.startsWith('localhost') ? 'http' : 'https';
23+
const origin = env.NEXT_PUBLIC_BETTER_AUTH_URL ?? `${proto}://${host}`;
24+
return `${origin}/${orgId}/settings/billing`;
25+
}
26+
1927
export async function subscribeToPentestPlan(
2028
orgId: string,
21-
returnBaseUrl: string,
2229
): Promise<{ url: string }> {
2330
await requireOrgMember(orgId);
31+
const returnBaseUrl = await getOrgBillingUrl(orgId);
2432

2533
if (!stripe) {
2634
throw new Error('Stripe is not configured.');
@@ -170,9 +178,9 @@ export async function handleSubscriptionSuccess(
170178

171179
export async function createBillingPortalSession(
172180
orgId: string,
173-
returnUrl: string,
174181
): Promise<{ url: string }> {
175182
await requireOrgMember(orgId);
183+
const returnUrl = await getOrgBillingUrl(orgId);
176184

177185
if (!stripe) {
178186
throw new Error('Stripe is not configured.');
@@ -197,6 +205,15 @@ export async function createBillingPortalSession(
197205
export async function checkAndChargePentestBilling(orgId: string, runId: string): Promise<void> {
198206
await requireOrgMember(orgId);
199207

208+
// Verify the run exists and belongs to this org to prevent arbitrary runId abuse.
209+
const run = await db.securityPenetrationTestRun.findUnique({
210+
where: { id: runId },
211+
select: { organizationId: true },
212+
});
213+
if (!run || run.organizationId !== orgId) {
214+
throw new Error('Run not found.');
215+
}
216+
200217
const subscription = await db.pentestSubscription.findUnique({
201218
where: { organizationId: orgId },
202219
include: { organizationBilling: true },

apps/app/src/app/(app)/[orgId]/settings/billing/page.tsx

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { env } from '@/env.mjs';
21
import { auth } from '@/utils/auth';
32
import { db } from '@db';
4-
import { Button, PageHeader, PageLayout } from '@trycompai/design-system';
3+
import { Button } from '@trycompai/design-system';
54
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card';
65
import { headers } from 'next/headers';
76
import type { Metadata } from 'next';
@@ -77,20 +76,8 @@ export default async function BillingPage({ params, searchParams }: BillingPageP
7776
})
7877
: null;
7978

80-
// Prefer the explicit base URL env var; fall back to the request origin so
81-
// Stripe always receives an absolute URL (relative URLs are rejected).
82-
const requestHeaders = await headers();
83-
const host = requestHeaders.get('host') ?? '';
84-
const proto = host.startsWith('localhost') ? 'http' : 'https';
85-
const origin = env.NEXT_PUBLIC_BETTER_AUTH_URL ?? `${proto}://${host}`;
86-
const billingUrl = `${origin}/${orgId}/settings/billing`;
87-
8879
return (
89-
<PageLayout>
90-
<PageHeader title="Billing">
91-
Manage your subscriptions and payment methods.
92-
</PageHeader>
93-
80+
<div className="space-y-6">
9481
{successMessage && (
9582
<div className="rounded-md bg-green-50 border border-green-200 p-4 text-green-800 text-sm">
9683
{successMessage}
@@ -119,7 +106,7 @@ export default async function BillingPage({ params, searchParams }: BillingPageP
119106
<form
120107
action={async () => {
121108
'use server';
122-
const { url } = await createBillingPortalSession(orgId, billingUrl);
109+
const { url } = await createBillingPortalSession(orgId);
123110
redirect(url);
124111
}}
125112
>
@@ -184,7 +171,7 @@ export default async function BillingPage({ params, searchParams }: BillingPageP
184171
<form
185172
action={async () => {
186173
'use server';
187-
const { url } = await subscribeToPentestPlan(orgId, billingUrl);
174+
const { url } = await subscribeToPentestPlan(orgId);
188175
redirect(url);
189176
}}
190177
>
@@ -193,7 +180,7 @@ export default async function BillingPage({ params, searchParams }: BillingPageP
193180
)}
194181
</CardContent>
195182
</Card>
196-
</PageLayout>
183+
</div>
197184
);
198185
}
199186

0 commit comments

Comments
 (0)