Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
108 changes: 108 additions & 0 deletions packages/app/control/scripts/seed-test-referral-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* Script to seed test data for local template referral testing
*
* Creates:
* - Test user with GitHub link
* - Test app with membership
* - Returns app ID for use with echo-start
*/

import { PrismaClient } from '../src/generated/prisma/index.js';

const prisma = new PrismaClient();

async function main() {
console.log('Seeding test data for template referral system...\n');

// Create or get test user
const testEmail = '[email protected]';
let user = await prisma.user.findUnique({
where: { email: testEmail },
include: { githubLink: true },
});

if (!user) {
console.log('Creating test user...');
user = await prisma.user.create({
data: {
email: testEmail,
name: 'Test Template User',
githubLink: {
create: {
githubId: 123456,
githubType: 'user',
githubUrl: 'https://github.com/Trynax',
},
},
},
include: { githubLink: true },
});
console.log(`Created user: ${user.email} (ID: ${user.id})`);
} else {
console.log(`Found existing user: ${user.email} (ID: ${user.id})`);

// Ensure GitHub link exists
if (!user.githubLink) {
await prisma.githubLink.create({
data: {
userId: user.id,
githubId: 123456,
githubType: 'user',
githubUrl: 'https://github.com/Trynax',
},
});
console.log('✅ Added GitHub link for Trynax');
}
}

// Create or get test app
const testAppName = 'Test Template Referral App';
let app = await prisma.echoApp.findFirst({
where: { name: testAppName },
include: { appMemberships: true },
});

if (!app) {
console.log('\nCreating test app...');
app = await prisma.echoApp.create({
data: {
name: testAppName,
appMemberships: {
create: {
userId: user.id,
role: 'OWNER',
totalSpent: 0,
},
},
},
include: { appMemberships: true },
});
console.log(`Created app: ${app.name} (ID: ${app.id})`);
} else {
console.log(`\nFound existing app: ${app.name} (ID: ${app.id})`);
}

console.log('\nTest Data Summary:');
console.log('─────────────────────────────────────────────────────');
console.log(`User ID: ${user.id}`);
console.log(`User Email: ${user.email}`);
console.log(`GitHub URL: ${user.githubLink?.githubUrl || 'https://github.com/Trynax'}`);
console.log(`App ID: ${app.id}`);
console.log(`App Name: ${app.name}`);
console.log('─────────────────────────────────────────────────────');

console.log('\nTest Command:');
console.log(`cd /tmp && /root/developments/opensource/echo/packages/sdk/echo-start/dist/index.js \\`);
console.log(` --template https://github.com/Trynax/commitcraft \\`);
console.log(` --app-id ${app.id} \\`);
console.log(` test-echo-local\n`);
}

main()
.catch((e) => {
console.error('Error seeding data:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextResponse, NextRequest } from 'next/server';
import {
registerTemplateReferral,
registerTemplateReferralSchema,
} from '@/services/db/apps/template-referral';
import { db } from '@/services/db/client';
import { AppRole } from '@/services/db/apps/permissions/types';

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validatedData = registerTemplateReferralSchema.parse(body);
const { appId, githubUsername, templateUrl } = validatedData;

const app = await db.echoApp.findUnique({
where: { id: appId },
include: {
appMemberships: {
where: { role: AppRole.OWNER },
take: 1,
},
},
});

if (!app || app.appMemberships.length === 0) {
return NextResponse.json(
{ success: false, message: 'App not found' },
{ status: 404 }
);
}

const userId = app.appMemberships[0].userId;

const result = await registerTemplateReferral(userId, {
appId,
githubUsername,
templateUrl,
});
Copy link
Contributor

@vercel vercel bot Nov 8, 2025

Choose a reason for hiding this comment

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

The /api/v1/referrals/register-template endpoint lacks authentication and accepts any appId, allowing anyone to set arbitrary referrers for any app in the system. This is a critical authorization bypass.

View Details
📝 Patch Details
diff --git a/packages/app/control/src/app/api/v1/referrals/register-template/route.ts b/packages/app/control/src/app/api/v1/referrals/register-template/route.ts
index 5a855111..51b14eba 100644
--- a/packages/app/control/src/app/api/v1/referrals/register-template/route.ts
+++ b/packages/app/control/src/app/api/v1/referrals/register-template/route.ts
@@ -1,76 +1,65 @@
-import { NextResponse, NextRequest } from 'next/server';
+import { NextResponse } from 'next/server';
 import {
   registerTemplateReferral,
   registerTemplateReferralSchema,
 } from '@/services/db/apps/template-referral';
-import { db } from '@/services/db/client';
-import { AppRole } from '@/services/db/apps/permissions/types';
+import { authRoute } from '../../../../../lib/api/auth-route';
 
-export async function POST(request: NextRequest) {
-  try {
-    const body = await request.json();
-    const validatedData = registerTemplateReferralSchema.parse(body);
-    const { appId, githubUsername, templateUrl } = validatedData;
+export const POST = authRoute
+  .body(registerTemplateReferralSchema)
+  .handler(async (_, context) => {
+    try {
+      const { appId, githubUsername, templateUrl } = context.body;
+      const { userId, appId: contextAppId } = context.ctx;
 
-    const app = await db.echoApp.findUnique({
-      where: { id: appId },
-      include: {
-        appMemberships: {
-          where: { role: AppRole.OWNER },
-          take: 1,
-        },
-      },
-    });
-
-    if (!app || app.appMemberships.length === 0) {
-      return NextResponse.json(
-        { success: false, message: 'App not found' },
-        { status: 404 }
-      );
-    }
+      // Verify that the authenticated user owns the app they're trying to modify
+      if (contextAppId !== appId) {
+        return NextResponse.json(
+          { success: false, message: 'Access denied: You can only modify referrals for your own apps' },
+          { status: 403 }
+        );
+      }
 
-    const userId = app.appMemberships[0].userId;
+      const result = await registerTemplateReferral(userId, {
+        appId,
+        githubUsername,
+        templateUrl,
+      });
 
-    const result = await registerTemplateReferral(userId, {
-      appId,
-      githubUsername,
-      templateUrl,
-    });
+      if (result.status === 'registered') {
+        return NextResponse.json({
+          success: true,
+          status: 'registered',
+          message: `Template creator ${result.referrerUsername} registered as referrer`,
+          referrerUsername: result.referrerUsername,
+        });
+      }
 
-    if (result.status === 'registered') {
-      return NextResponse.json({
-        success: true,
-        status: 'registered',
-        message: `Template creator ${result.referrerUsername} registered as referrer`,
-        referrerUsername: result.referrerUsername,
-      });
-    }
+      if (result.status === 'skipped') {
+        return NextResponse.json({
+          success: true,
+          status: 'skipped',
+          message: result.reason || 'Referral skipped',
+          reason: result.reason,
+        });
+      }
 
-    if (result.status === 'skipped') {
       return NextResponse.json({
         success: true,
-        status: 'skipped',
-        message: result.reason || 'Referral skipped',
+        status: 'not_found',
+        message: result.reason || 'Template creator not found on Echo',
         reason: result.reason,
       });
-    }
-
-    return NextResponse.json({
-      success: true,
-      status: 'not_found',
-      message: result.reason || 'Template creator not found on Echo',
-      reason: result.reason,
-    });
-  } catch (error) {
-    const message =
-      error instanceof Error ? error.message : 'Unknown error occurred';
+    } catch (error) {
+      const message =
+        error instanceof Error ? error.message : 'Unknown error occurred';
 
-    return NextResponse.json(
-      {
-        success: false,
-        message,
-      },
-      { status: 400 }
-    );
-  }
-}
\ No newline at end of file
+      return NextResponse.json(
+        {
+          success: false,
+          message,
+        },
+        { status: 400 }
+      );
+    }
+  });
\ No newline at end of file

Analysis

Unauthenticated API endpoint allows unauthorized referral modification

What fails: /api/v1/referrals/register-template endpoint accepts any appId without authentication, allowing attackers to set themselves as referrers for arbitrary apps

How to reproduce:

# Call endpoint with any app ID and attacker's GitHub username:
curl -X POST https://echo.merit.systems/api/v1/referrals/register-template \
  -H "Content-Type: application/json" \
  -d '{"appId":"<any-app-id>","githubUsername":"attacker","templateUrl":"https://github.com/attacker/repo"}'

Result: Returns 200 OK and sets attacker as referrer for the target app's owner without permission checks. The endpoint looks up the app owner from the database and modifies their referral data directly.

Expected: Should return 401/403 Unauthorized. API endpoints that modify user data require authentication via authRoute middleware (like /api/v1/user/referral/route.ts)

Impact: Authorization bypass allows hijacking referral earnings by setting arbitrary referrers for any app in the system

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yoo @rsproule the authentication concern on this /api/v1/referrals/register-template, I believe this isn't significant because app builders typically won't reveal their app ID publicly before scaffolding with echo-start, and once a referrer is set, subsequent calls are automatically skipped so it can't be overridden by echo start.


if (result.status === 'registered') {
return NextResponse.json({
success: true,
status: 'registered',
message: `Template creator ${result.referrerUsername} registered as referrer`,
referrerUsername: result.referrerUsername,
});
}

if (result.status === 'skipped') {
return NextResponse.json({
success: true,
status: 'skipped',
message: result.reason || 'Referral skipped',
reason: result.reason,
});
}

return NextResponse.json({
success: true,
status: 'not_found',
message: result.reason || 'Template creator not found on Echo',
reason: result.reason,
});
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error occurred';

return NextResponse.json(
{
success: false,
message,
},
{ status: 400 }
);
}
}
137 changes: 137 additions & 0 deletions packages/app/control/src/services/db/apps/template-referral.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { z } from 'zod';
import { db } from '@/services/db/client';
import { appIdSchema } from './lib/schemas';
import { AppRole } from './permissions/types';

export const registerTemplateReferralSchema = z.object({
appId: appIdSchema,
githubUsername: z.string().min(1),
templateUrl: z.string().url(),
});

export type RegisterTemplateReferralInput = z.infer<
typeof registerTemplateReferralSchema
>;

export interface RegisterTemplateReferralResult {
status: 'registered' | 'skipped' | 'not_found';
reason?: string;
referrerUsername?: string;
referralCodeId?: string;
}

export async function registerTemplateReferral(
userId: string,
input: RegisterTemplateReferralInput
): Promise<RegisterTemplateReferralResult> {
const { appId, githubUsername, templateUrl } = input;

const app = await db.echoApp.findUnique({
where: { id: appId },
include: {
appMemberships: {
where: {
userId,
role: AppRole.OWNER,
},
},
},
});

if (!app || app.appMemberships.length === 0) {
throw new Error('App not found or user is not the owner');
}

const membership = await db.appMembership.findUnique({
where: {
userId_echoAppId: {
userId,
echoAppId: appId,
},
},
select: {
referrerId: true,
},
});

if (!membership) {
throw new Error('App membership not found');
}

if (membership.referrerId) {
return {
status: 'skipped',
reason: 'existing_referrer',
};
}

const githubLink = await db.githubLink.findFirst({
where: {
githubUrl: {
contains: githubUsername,
},
githubType: 'user',
isArchived: false,
},
include: {
user: {
select: {
id: true,
name: true,
},
},
},
});

if (!githubLink || !githubLink.user) {
return {
status: 'not_found',
reason: 'template_creator_not_on_echo',
};
}

let referralCode = await db.referralCode.findFirst({
where: {
userId: githubLink.user.id,
isArchived: false,
},
select: {
id: true,
},
});

if (!referralCode) {
const code = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setFullYear(expiresAt.getFullYear() + 1);

referralCode = await db.referralCode.create({
data: {
code,
userId: githubLink.user.id,
expiresAt,
},
select: {
id: true,
},
});
}

await db.appMembership.update({
where: {
userId_echoAppId: {
userId,
echoAppId: appId,
},
},
data: {
referrerId: referralCode.id,
},
});

return {
status: 'registered',
referrerUsername: githubUsername,
referralCodeId: referralCode.id,
};
}
Loading
Loading