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
1 change: 1 addition & 0 deletions packages/dashboard/src/app/api/deploy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export async function POST(request: NextRequest) {
supabaseAccessToken,
supabaseProjectRef,
stripeKey,
workerIntervalSeconds: 30,
})

// Create session to store credentials server-side (for Management API queries)
Expand Down
61 changes: 61 additions & 0 deletions packages/dashboard/src/app/api/sync-progress/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/sessions'

const SYNC_PROGRESS_QUERY = `
SELECT json_build_object(
'run', (
SELECT row_to_json(r) FROM (
SELECT account_id, started_at, closed_at, triggered_by, status,
total_processed, total_objects, complete_count, error_count,
running_count, pending_count, error_message
FROM stripe.sync_runs ORDER BY started_at DESC LIMIT 1
) r
),
'objects', COALESCE((
SELECT json_agg(row_to_json(p) ORDER BY p.object)
FROM stripe.sync_obj_progress p
), '[]'::json)
) AS result
`

export async function GET(request: NextRequest) {
try {
const sessionId = request.nextUrl.searchParams.get('sessionId')

if (!sessionId) {
return NextResponse.json({ error: 'Missing sessionId' }, { status: 400 })
}

const session = getSession(sessionId)
if (!session) {
return NextResponse.json({ error: 'Session expired' }, { status: 401 })
}

const response = await fetch(
`https://api.supabase.com/v1/projects/${session.projectRef}/database/query`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${session.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: SYNC_PROGRESS_QUERY }),
}
)
Comment on lines +34 to +44

Check failure

Code scanning / CodeQL

Server-side request forgery Critical

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

Copilot Autofix

AI 4 days ago

In general, the problem is that supabaseProjectRef (from the deploy request body) is stored as session.projectRef and later interpolated into a URL path without any validation. To fix this without changing the visible behavior, we should validate and normalize the project reference at the point of intake in packages/dashboard/src/app/api/deploy/route.ts, rejecting values that are clearly malformed, and store only this sanitized value in the session. That way, by the time sync-progress/route.ts reads session.projectRef, it is guaranteed to be a safe path segment for the Supabase API URL.

The single best fix here is:

  • In deploy/route.ts, add validation logic right after reading supabaseProjectRef from the request body:
    • Trim whitespace.
    • Ensure it matches a conservative pattern of allowed characters (e.g., lowercase letters, digits, and dashes), which aligns with typical Supabase project ref formats like abcd1234.
    • Optionally enforce a reasonable length bound.
    • If the value fails validation, return a 400 error.
  • Use the sanitized value (e.g., normalizedSupabaseProjectRef) in both:
    • The call to install({ supabaseProjectRef: ... }).
    • The call to createSession(...).
  • No change is required in sync-progress/route.ts because it will now only ever see a validated projectRef.

This keeps existing functionality (deploying and storing sessions) but prevents arbitrary, potentially dangerous path components from being persisted and later used to build request URLs. No new external dependencies are strictly necessary; we can implement validation with a simple regular expression.

Concretely:

  • Edit packages/dashboard/src/app/api/deploy/route.ts:
    • After destructuring supabaseAccessToken, supabaseProjectRef, stripeKey, add validation logic.
    • Introduce a new variable, e.g., const normalizedSupabaseProjectRef = supabaseProjectRef.trim(); then verify it with a regex like /^[a-z0-9-]{1,64}$/.
    • If invalid, return 400 with an error message.
    • Use normalizedSupabaseProjectRef instead of supabaseProjectRef when calling install and createSession.
  • No changes are needed in sessions.ts or sync-progress/route.ts because they operate on the sanitized value stored in the session.
Suggested changeset 1
packages/dashboard/src/app/api/deploy/route.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/dashboard/src/app/api/deploy/route.ts b/packages/dashboard/src/app/api/deploy/route.ts
--- a/packages/dashboard/src/app/api/deploy/route.ts
+++ b/packages/dashboard/src/app/api/deploy/route.ts
@@ -17,15 +17,22 @@
       return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
     }
 
+    const normalizedSupabaseProjectRef = supabaseProjectRef.trim()
+    // Allow only typical Supabase project ref characters to avoid unsafe URL path segments
+    const projectRefPattern = /^[a-z0-9-]{1,64}$/
+    if (!projectRefPattern.test(normalizedSupabaseProjectRef)) {
+      return NextResponse.json({ error: 'Invalid Supabase project reference' }, { status: 400 })
+    }
+
     await install({
       supabaseAccessToken,
-      supabaseProjectRef,
+      supabaseProjectRef: normalizedSupabaseProjectRef,
       stripeKey,
       workerIntervalSeconds: 30,
     })
 
     // Create session to store credentials server-side (for Management API queries)
-    const sessionId = createSession(supabaseProjectRef, supabaseAccessToken)
+    const sessionId = createSession(normalizedSupabaseProjectRef, supabaseAccessToken)
 
     return NextResponse.json({
       success: true,
EOF
@@ -17,15 +17,22 @@
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
}

const normalizedSupabaseProjectRef = supabaseProjectRef.trim()
// Allow only typical Supabase project ref characters to avoid unsafe URL path segments
const projectRefPattern = /^[a-z0-9-]{1,64}$/
if (!projectRefPattern.test(normalizedSupabaseProjectRef)) {
return NextResponse.json({ error: 'Invalid Supabase project reference' }, { status: 400 })
}

await install({
supabaseAccessToken,
supabaseProjectRef,
supabaseProjectRef: normalizedSupabaseProjectRef,
stripeKey,
workerIntervalSeconds: 30,
})

// Create session to store credentials server-side (for Management API queries)
const sessionId = createSession(supabaseProjectRef, supabaseAccessToken)
const sessionId = createSession(normalizedSupabaseProjectRef, supabaseAccessToken)

return NextResponse.json({
success: true,
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False positive, user provides a projectRef as intended


if (!response.ok) {
const text = await response.text()
return NextResponse.json({ error: `Database query failed: ${text}` }, { status: 500 })
}

const rows = await response.json()
const data = rows?.[0]?.result ?? { run: null, objects: [] }

return NextResponse.json(data)
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
)
}
}
25 changes: 15 additions & 10 deletions packages/dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from 'react'
import { DeployForm } from '@/components/DeployForm'
import { SyncStatus } from '@/components/SyncStatus'
import { SyncProgress } from '@/components/SyncProgress'
import { InstallationStatus } from '@/components/InstallationStatus'

export default function Home() {
Expand All @@ -15,16 +16,18 @@ export default function Home() {
<h1 style={{ marginBottom: 8 }}>Stripe Sync</h1>
<p style={{ color: '#666', marginBottom: 32 }}>Deploy Stripe sync to your Supabase project</p>

<DeployForm
onDeploying={setDeploying}
onSuccess={(id) => {
setDeploying(false)
setSessionId(id)
setInstallationComplete(false)
}}
/>
{!sessionId && !deploying && (
<DeployForm
onDeploying={setDeploying}
onSuccess={(id) => {
setDeploying(false)
setSessionId(id)
setInstallationComplete(false)
}}
/>
)}

<div style={{ marginTop: 32 }}>
<div style={{ marginTop: sessionId || deploying ? 0 : 32 }}>
{deploying && (
<div style={statusStyle}>
<span style={{ fontSize: 20 }}>🚀</span>
Expand All @@ -44,8 +47,10 @@ export default function Home() {

{!deploying && sessionId && installationComplete && (
<div>
<h2 style={{ fontSize: 18, marginBottom: 16 }}>Sync Status</h2>
<SyncStatus sessionId={sessionId} />
<div style={{ marginTop: 24 }}>
<SyncProgress sessionId={sessionId} />
</div>
</div>
)}
</div>
Expand Down
176 changes: 176 additions & 0 deletions packages/dashboard/src/components/SyncProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
'use client'

import { useEffect, useState, useCallback } from 'react'

interface ObjectProgress {
object: string
pct_complete: number
processed: number
}

interface SyncRun {
account_id: string
started_at: string
closed_at: string | null
status: string
total_processed: number
total_objects: number
complete_count: number
error_count: number
running_count: number
pending_count: number
}

interface SyncProgressProps {
sessionId: string
}

function barColor(pct: number): string {
if (pct >= 100) return '#16a34a'
if (pct > 0) return '#2563eb'
return '#9ca3af'
}

export function SyncProgress({ sessionId }: SyncProgressProps) {
const [run, setRun] = useState<SyncRun | null>(null)
const [objects, setObjects] = useState<ObjectProgress[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)

const fetchData = useCallback(async () => {
try {
const res = await fetch(`/api/sync-progress?sessionId=${sessionId}`)
const data = await res.json()
if (!res.ok) {
setError(data.error)
return
}
setRun(data.run)
setObjects(data.objects ?? [])
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch')
} finally {
setLoading(false)
}
}, [sessionId])

useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 2000)
return () => clearInterval(interval)
}, [fetchData])

if (loading) return <div style={containerStyle}>Loading...</div>

if (error) {
return (
<div style={{ ...containerStyle, background: '#fff3cd', color: '#856404' }}>
<span style={{ fontSize: 20 }}>⚠️</span> {error}
</div>
)
}

if (!run || objects.length === 0) return null

const totalRows = objects.reduce((sum, o) => sum + Number(o.processed), 0)

return (
<div style={tableWrapStyle}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr>
<th style={{ ...thStyle, textAlign: 'left' }}>Table</th>
<th style={{ ...thStyle, textAlign: 'left', width: '35%' }}>Progress</th>
<th style={{ ...thStyle, textAlign: 'right' }}>Rows</th>
</tr>
</thead>
<tbody>
{objects.map((obj) => {
const pct = Number(obj.pct_complete)
return (
<tr key={obj.object} style={{ borderBottom: '1px solid #eee' }}>
<td style={tdStyle}>
<span style={{ fontWeight: 500 }}>{obj.object}</span>
</td>
<td style={tdStyle}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={trackStyle}>
<div
style={{
height: '100%',
width: `${pct}%`,
borderRadius: 3,
background: barColor(pct),
transition: 'width 0.5s ease',
}}
/>
</div>
<span style={{ fontSize: 12, color: '#888', minWidth: 42, textAlign: 'right' }}>
{pct.toFixed(1)}%
</span>
</div>
</td>
<td style={{ ...tdStyle, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{Number(obj.processed).toLocaleString()}
</td>
</tr>
)
})}
</tbody>
</table>
<div style={footerStyle}>
<span style={{ fontWeight: 600 }}>{totalRows.toLocaleString()} total rows</span>
</div>
</div>
)
}

const containerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '16px 20px',
background: '#f9f9f9',
borderRadius: 8,
fontSize: 16,
}

const tableWrapStyle: React.CSSProperties = {
border: '1px solid #ddd',
borderRadius: 8,
overflow: 'hidden',
}

const thStyle: React.CSSProperties = {
padding: '10px 16px',
fontSize: 12,
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: 0.5,
color: '#888',
background: '#fafafa',
borderBottom: '1px solid #ddd',
}

const tdStyle: React.CSSProperties = {
padding: '10px 16px',
verticalAlign: 'middle',
}

const trackStyle: React.CSSProperties = {
flex: 1,
height: 6,
background: '#eee',
borderRadius: 3,
overflow: 'hidden',
}

const footerStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '10px 16px',
background: '#fafafa',
borderTop: '1px solid #ddd',
fontSize: 13,
}