Skip to content

[Security] 17 findings (13 High, 3 Medium, 1 Low) #1

Description

@pvz122

Security report for ptengelmann/PhantomOS

Hello maintainers,

I am a security researcher studying security risks in vibe-coded software. During this research, I reviewed this repository and identified the findings below. These findings were identified in commit cf30cb2. Each finding has undergone human analysis, but the report may still contain mistakes or incomplete interpretations. Please review this report and apply the necessary security fixes.

This report contains 17 confirmed findings identified during security review. Validation details below distinguish code evidence from runtime observations and note any limitations.

Summary

ID Severity Category Affected area
SEC-001 High Cryptographic Failures src/app/api/connectors/shopify/callback/route.ts
SEC-002 High Broken Access Control src/app/api/ai/tagging/route.ts
SEC-003 High Broken Access Control src/app/api/ai/tagging/route.ts
SEC-004 High Authentication Failures src/app/api/connectors/shopify/auth/route.ts
src/app/api/connectors/shopify/callback/route.ts
SEC-005 High Security Misconfiguration src/app/api/connectors/[id]/route.ts
src/lib/auth/index.ts
SEC-006 High Broken Access Control src/app/api/ai/forecast/route.ts
SEC-007 High Broken Access Control src/app/api/ai/insights/route.ts
SEC-008 High Injection src/app/api/waitlist/route.ts
src/app/admin/page.tsx
SEC-009 High Broken Access Control src/app/api/connectors/shopify/auth/route.ts
src/app/api/connectors/shopify/callback/route.ts
src/components/dashboard/connector-wizard.tsx
SEC-010 High Broken Access Control middleware.ts
src/app/admin/page.tsx
SEC-011 High Authentication Failures src/app/api/settings/invite/validate/route.ts
SEC-012 High Authentication Failures src/app/api/waitlist/route.ts
SEC-013 High Broken Access Control src/app/api/connectors/shopify/sync/products/route.ts
src/app/api/connectors/shopify/sync/orders/route.ts
SEC-014 Medium Broken Access Control src/lib/rate-limit/index.ts
src/lib/audit/index.ts
SEC-015 Medium Insecure Design src/app/api/products/import/route.ts
src/app/api/sales/import/route.ts
src/app/api/settings/profile/route.ts
src/app/(dashboard)/settings/page.tsx
SEC-016 Medium Security Logging and Alerting Failures src/app/api/settings/invite/route.ts
SEC-017 Low Security Logging and Alerting Failures src/app/api/settings/invite/route.ts
src/app/api/waitlist/approve/route.ts

1. Encrypt Shopify Access Token Before Persisting to Database

ID: SEC-001
Severity: High
Category: Cryptographic Failures
Affected code: src/app/api/connectors/shopify/callback/route.ts

Impact

Plaintext Shopify access tokens stored in the database can be exposed through database reads, backups, logs, or a database breach and then used to access connected Shopify stores.

Technical details

  • src/app/api/connectors/shopify/callback/route.ts stores credentials as { accessToken }.
  • The callback does not import or call encryptCredentials.
  • The code contains a comment stating encryption is intended for production, while sync code expects encrypted credentials.

Relevant code

src/app/api/connectors/shopify/callback/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { connectors } from '@/lib/db/schema';
import crypto from 'crypto';

const SHOPIFY_API_KEY = process.env.SHOPIFY_API_KEY || '';
const SHOPIFY_API_SECRET = process.env.SHOPIFY_API_SECRET || '';

// Verify Shopify HMAC signature
function verifyHmac(query: URLSearchParams): boolean {
  const hmac = query.get('hmac');
  if (!hmac) return false;

  // Remove hmac from query params for verification
  const params = new URLSearchParams(query);
  params.delete('hmac');

  // Sort params and create message
  const sortedParams = Array.from(params.entries())
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([key, value]) => `${key}=${value}`)
    .join('&');

  const generatedHmac = crypto
    .createHmac('sha256', SHOPIFY_API_SECRET)
    .update(sortedParams)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(hmac),

Validation

Code evidence

  • callback/route.ts stores credentials: { accessToken } directly.

  • The callback has no encryptCredentials import or invocation.
    Limitations

  • No database or token-exfiltration probe was performed; the finding is confirmed from source code.

Recommended remediation

Encrypt the Shopify access token with the existing encryption utility before persistence, and ensure all read paths decrypt it only when required.

2. Add Authentication and Rate Limiting to Bulk AI Tagging PUT Handler

ID: SEC-002
Severity: High
Category: Broken Access Control
Affected code: src/app/api/ai/tagging/route.ts

Impact

The PUT tagging path can invoke the paid Anthropic API without the publisher resolution and AI rate limit used by the sibling POST path. This permits authenticated non-publisher users to cause unbounded API expenditure and may expose cross-tenant mapping data.

Technical details

  • The PUT handler omits resolvePublisher() and rateLimit('ai').
  • The handler calls getConfirmedMappingExamples(undefined, 20), which queries mappings without a publisher filter.
  • The middleware blocks unauthenticated requests, but session authentication alone does not establish a publisher for this path.

Relevant code

src/app/api/ai/tagging/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import { resolvePublisher } from '@/lib/auth';
import { db } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { rateLimit } from '@/lib/rate-limit';

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

// Fetch confirmed mappings to use as few-shot examples
async function getConfirmedMappingExamples(category?: string, limit: number = 15): Promise<string> {
  try {
    // Get confirmed mappings from across all publishers (the network effect!)
    const examples = await db.execute(sql`
      SELECT
        p.name as product_name,
        p.category as product_category,
        ia.name as asset_name,
        ia.asset_type
      FROM product_assets pa
      JOIN products p ON p.id = pa.product_id
      JOIN ip_assets ia ON ia.id = pa.asset_id
      ORDER BY
        CASE WHEN p.category = ${category || ''} THEN 0 ELSE 1 END,
        pa.created_at DESC
      LIMIT ${limit}
    `);

Validation

Code evidence

  • src/app/api/ai/tagging/route.ts imports resolvePublisher and rateLimit, but the PUT path does not invoke them.

  • The POST path applies both controls.
    Runtime evidence

  • An unauthenticated PUT to /api/ai/tagging redirected to /login.
    Limitations

  • The supplied runtime check did not exercise an authenticated non-publisher session or confirm an Anthropic charge.

Recommended remediation

Require resolvePublisher() and rateLimit('ai') before processing PUT requests, and scope all example queries to the resolved publisher.

3. Cross-Tenant Data Leak: Competitor Product Names Included in AI Prompts

ID: SEC-003
Severity: High
Category: Broken Access Control
Affected code: src/app/api/ai/tagging/route.ts

Impact

Authenticated publishers may have competitor product and asset names included in their AI prompts, disclosing tenant data to another tenant and to the configured AI provider.

Technical details

  • getConfirmedMappingExamples() joins product_assets, products, and ip_assets without a WHERE publisher_id predicate.
  • The returned names are inserted into the prompt as LEARNED PATTERNS.
  • The query therefore selects mappings across publishers rather than only the caller's tenant.

Relevant code

src/app/api/ai/tagging/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import Anthropic from '@anthropic-ai/sdk';
import { resolvePublisher } from '@/lib/auth';
import { db } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { rateLimit } from '@/lib/rate-limit';

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

// Fetch confirmed mappings to use as few-shot examples
async function getConfirmedMappingExamples(category?: string, limit: number = 15): Promise<string> {
  try {
    // Get confirmed mappings from across all publishers (the network effect!)
    const examples = await db.execute(sql`
      SELECT
        p.name as product_name,
        p.category as product_category,
        ia.name as asset_name,
        ia.asset_type
      FROM product_assets pa
      JOIN products p ON p.id = pa.product_id
      JOIN ip_assets ia ON ia.id = pa.asset_id
      ORDER BY
        CASE WHEN p.category = ${category || ''} THEN 0 ELSE 1 END,
        pa.created_at DESC
      LIMIT ${limit}
    `);

Validation

Code evidence

  • src/app/api/ai/tagging/route.ts contains a raw query with no publisher_id filter.

  • The function is used to build AI tagging context.
    Runtime evidence

  • A POST to /api/ai/tagging without authentication redirected to /login.
    Limitations

  • The supplied runtime evidence did not demonstrate a cross-tenant response using two authenticated tenants.

Recommended remediation

Pass the resolved publisher ID into getConfirmedMappingExamples() and add a tenant predicate to the query before using results in prompts.

4. Shopify OAuth State Unsigned and Nonce Unverified — CSRF and Session Fixation Risk

ID: SEC-004
Severity: High
Category: Authentication Failures
Affected code: src/app/api/connectors/shopify/auth/route.ts, src/app/api/connectors/shopify/callback/route.ts

Impact

Shopify OAuth state is not bound to the initiating session and the nonce is not verified. An attacker may forge state values, including publisherId, creating CSRF and account-association risk. HMAC verification also fails open when SHOPIFY_API_SECRET is unset.

Technical details

  • The auth route encodes nonce, publisherId, and shop as plain base64 JSON.
  • The nonce is generated but not stored server-side.
  • The callback decodes publisherId without session or nonce binding.
  • The callback checks HMAC only when SHOPIFY_API_SECRET is non-empty.

Relevant code

src/app/api/connectors/shopify/auth/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession, isDemoMode, getDemoPublisherId, canWrite } from '@/lib/auth';
import crypto from 'crypto';

// Shopify OAuth configuration
const SHOPIFY_API_KEY = process.env.SHOPIFY_API_KEY || '';
const SHOPIFY_SCOPES = 'read_products,read_orders,read_customers';

// Generate a random nonce for CSRF protection
function generateNonce(): string {
  return crypto.randomBytes(16).toString('hex');
}

// Initiate Shopify OAuth flow
export async function POST(request: NextRequest) {
  try {
    // SECURITY: Require write access (owner/admin only)
    let publisherId: string;

    const session = await getServerSession();

    if (session?.user?.publisherId) {
      // User is logged in - always check RBAC regardless of demo mode
      if (!canWrite(session.user.role)) {
        return NextResponse.json({ error: 'Write access required' }, { status: 403 });
      }
      publisherId = session.user.publisherId;
    } else if (isDemoMode()) {
      // No session but demo mode - allow anonymous access
      publisherId = getDemoPublisherId();

Validation

Code evidence

  • auth/route.ts creates state with Buffer.from(JSON.stringify({ nonce, publisherId, shop })).toString('base64').

  • callback/route.ts uses if (SHOPIFY_API_SECRET && !verifyHmac(searchParams)).
    Runtime evidence

  • An unauthenticated callback request redirected to /login.
    Limitations

  • The supplied probe did not complete an OAuth flow or demonstrate connector creation.

Recommended remediation

Bind state to the initiating session, authenticate and verify its integrity, persist and validate the nonce, and reject callbacks when the Shopify secret is absent.

5. Add Production Safeguard to Prevent Demo Mode Bypassing Authentication

ID: SEC-005
Severity: High
Category: Security Misconfiguration
Affected code: src/app/api/connectors/[id]/route.ts, src/lib/auth/index.ts

Impact

Setting DEMO_MODE=true in production can bypass authentication for routes that use the demo branch, expose connector data, and permit unauthenticated operations against the fixed demo publisher.

Technical details

  • isDemoMode() is an environment-variable check with no production guard.
  • GET /api/connectors/[id] selects the demo publisher when no session exists and demo mode is enabled.
  • DEMO_MODE is absent from .env.example.

Relevant code

src/app/api/connectors/[id]/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { connectors, products, sales, productAssets } from '@/lib/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { getServerSession, isDemoMode, getDemoPublisherId, canWrite } from '@/lib/auth';

// DELETE - Disconnect/delete a connector and its associated data
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  try {
    const { id: connectorId } = await params;
    let publisherId: string;

    // SECURITY: Require write access (owner/admin only)
    const session = await getServerSession();

    if (session?.user?.publisherId) {
      // User is logged in - always check RBAC regardless of demo mode
      if (!canWrite(session.user.role)) {
        return NextResponse.json({ error: 'Write access required' }, { status: 403 });
      }
      publisherId = session.user.publisherId;
    } else if (isDemoMode()) {
      // No session but demo mode - allow anonymous access
      publisherId = getDemoPublisherId();
    } else {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

Validation

Code evidence

  • src/app/api/connectors/[id]/route.ts checks isDemoMode() before returning unauthorized.

  • The demo branch assigns getDemoPublisherId() without requiring a session.
    Runtime evidence

  • The deployed application redirected an unauthenticated connector request to /login, indicating demo mode was not active in the tested deployment.
    Limitations

  • The deployed environment was not tested with DEMO_MODE=true.

Recommended remediation

Add a startup assertion that rejects DEMO_MODE=true when NODE_ENV=production, and document the setting and its security implications.

6. IDOR in AI Forecast Endpoint Allows Cross-Tenant Data Access

ID: SEC-006
Severity: High
Category: Broken Access Control
Affected code: src/app/api/ai/forecast/route.ts

Impact

An authenticated user can request forecast data for another tenant's product or asset by supplying its identifier, potentially retrieving foreign sales history and product details.

Technical details

  • The handler resolves publisherId but does not use it in productId or assetId query branches.
  • Product-specific sales queries filter by product_id only.
  • Asset-specific queries similarly omit publisher_id.
  • The fallback branch does apply publisher scoping, creating inconsistent tenant enforcement.

Relevant code

src/app/api/ai/forecast/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { predictDemand } from '@/lib/ai';
import { db } from '@/lib/db';
import { sql } from 'drizzle-orm';
import { resolvePublisher } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit';

export async function POST(request: NextRequest) {
  try {
    // Rate limit AI endpoints (expensive operations)
    const rateLimitResponse = await rateLimit('ai');
    if (rateLimitResponse) return rateLimitResponse;

    // SECURITY: Session-first pattern - always check auth before demo mode
    const resolved = await resolvePublisher();
    if (!resolved) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }
    const { publisherId } = resolved;

    const body = await request.json();
    const { productId, assetId } = body;

    // Build the query based on whether we're forecasting for a product or asset
    let salesQuery;
    let entityData;

    if (productId) {
      // Product-specific forecast
      salesQuery = sql`

Validation

Code evidence

  • src/app/api/ai/forecast/route.ts obtains publisherId through resolvePublisher().

  • The productId and assetId SQL branches use identifier predicates without publisher_id.
    Runtime evidence

  • An unauthenticated POST to /api/ai/forecast returned a 307 authentication redirect.
    Limitations

  • No authenticated cross-tenant identifier was used in the supplied probe.

Recommended remediation

Add publisher ownership predicates or tenant-scoped joins to every productId and assetId query, and reject identifiers not owned by the resolved publisher.

7. IDOR on AI Insights PATCH Allows Cross-Tenant State Manipulation

ID: SEC-007
Severity: High
Category: Broken Access Control
Affected code: src/app/api/ai/insights/route.ts

Impact

An authenticated user can modify or dismiss another tenant's AI insight by supplying its identifier, causing cross-tenant state manipulation.

Technical details

  • The PATCH handler checks that a session publisher exists but does not use publisherId in the update predicate.
  • The update matches only aiInsights.id = insightId.
  • A foreign insight ID can therefore reach the write operation if known.

Relevant code

src/app/api/ai/insights/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { generateInsights } from '@/lib/ai';
import { db } from '@/lib/db';
import { sql, eq, desc, isNull, and } from 'drizzle-orm';
import { resolvePublisher, getServerSession } from '@/lib/auth';
import { aiInsights, gameIps } from '@/lib/db/schema';
import { v4 as uuidv4 } from 'uuid';
import { rateLimit } from '@/lib/rate-limit';
import { audit } from '@/lib/audit';

// GET: Retrieve stored insights with history support
export async function GET(request: NextRequest) {
  try {
    // SECURITY: Session-first pattern
    const resolved = await resolvePublisher();
    if (!resolved) {
      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }
    const { publisherId } = resolved;

    // Get query params
    const { searchParams } = new URL(request.url);
    const limit = parseInt(searchParams.get('limit') || '50');
    const includeHistory = searchParams.get('history') === 'true';
    const compareBatchA = searchParams.get('compareA');
    const compareBatchB = searchParams.get('compareB');

    // Fetch all insights from database
    const insights = await db
      .select({

Validation

Code evidence

  • src/app/api/ai/insights/route.ts PATCH updates aiInsights using only eq(aiInsights.id, insightId).

  • session.user.publisherId is available during the handler but is absent from the update condition.
    Runtime evidence

  • An unauthenticated PATCH request redirected to /login.
    Limitations

  • The supplied runtime check did not perform an authenticated cross-tenant update.

Recommended remediation

Include the resolved publisher ID in the update predicate, or first load and verify ownership before applying the change.

8. Stored XSS via Unvalidated companyWebsite URL Rendered in Admin Panel

ID: SEC-008
Severity: High
Category: Injection
Affected code: src/app/api/waitlist/route.ts, src/app/admin/page.tsx

Impact

A public waitlist submission can store a javascript: URL that executes JavaScript when an administrator follows the rendered link.

Technical details

  • The waitlist endpoint stores companyWebsite without validating its scheme.
  • The admin page renders the value directly in an anchor href.
  • The attacker-controlled value therefore flows from public input to a privileged admin view.

Relevant code

src/app/api/waitlist/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { waitlist } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { rateLimit } from '@/lib/rate-limit';
import { createAuditLog } from '@/lib/audit';
import { sendWaitlistNotificationToAdmin, isEmailConfigured } from '@/lib/email';

// POST - Add to waitlist
export async function POST(request: NextRequest) {
  try {
    // Rate limit public submissions (strict - 3 per minute)
    const rateLimitResponse = await rateLimit('public');
    if (rateLimitResponse) return rateLimitResponse;

    const body = await request.json();
    const { email, companyName, companyWebsite, revenueRange, primaryChannel } = body;

    if (!email) {
      return NextResponse.json(
        { error: 'Email is required' },
        { status: 400 }
      );
    }

    // Validate email format
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      return NextResponse.json(
        { error: 'Please enter a valid email address' },

Validation

Code evidence

  • src/app/api/waitlist/route.ts stores companyWebsite without URL scheme validation.

  • src/app/admin/page.tsx renders entry.companyWebsite as an anchor href.
    Runtime evidence

  • A POST with companyWebsite set to javascript:alert(1) returned HTTP 200 and persisted an entry.
    Limitations

  • The supplied runtime evidence confirms persistence but does not document execution in an administrator browser.

Recommended remediation

Allow only approved URL schemes and validate URLs server-side before storage; also apply safe scheme validation when rendering links.

9. Fix SSRF and Open Redirect via Weak Shopify Shop Domain Validation

ID: SEC-009
Severity: High
Category: Broken Access Control
Affected code: src/app/api/connectors/shopify/auth/route.ts, src/app/api/connectors/shopify/callback/route.ts, src/components/dashboard/connector-wizard.tsx

Impact

Weak Shopify domain validation can redirect users to attacker-controlled hosts and may cause the callback to send Shopify access credentials to an unintended endpoint.

Technical details

  • The auth route accepts shop values when they merely include .myshopify.com.
  • A value such as evil.com.myshopify.com.attacker.com passes that check.
  • The callback uses the shop parameter in https://${shop}/admin/oauth/access_token.
  • The client assigns the returned authUrl directly to window.location.href.

Relevant code

src/app/api/connectors/shopify/auth/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { getServerSession, isDemoMode, getDemoPublisherId, canWrite } from '@/lib/auth';
import crypto from 'crypto';

// Shopify OAuth configuration
const SHOPIFY_API_KEY = process.env.SHOPIFY_API_KEY || '';
const SHOPIFY_SCOPES = 'read_products,read_orders,read_customers';

// Generate a random nonce for CSRF protection
function generateNonce(): string {
  return crypto.randomBytes(16).toString('hex');
}

// Initiate Shopify OAuth flow
export async function POST(request: NextRequest) {
  try {
    // SECURITY: Require write access (owner/admin only)
    let publisherId: string;

    const session = await getServerSession();

    if (session?.user?.publisherId) {
      // User is logged in - always check RBAC regardless of demo mode
      if (!canWrite(session.user.role)) {
        return NextResponse.json({ error: 'Write access required' }, { status: 403 });
      }
      publisherId = session.user.publisherId;
    } else if (isDemoMode()) {
      // No session but demo mode - allow anonymous access
      publisherId = getDemoPublisherId();

Validation

Code evidence

  • auth/route.ts uses shopDomain.includes('.myshopify.com').

  • callback/route.ts constructs a fetch target from the shop parameter.

  • connector-wizard.tsx navigates unconditionally to data.authUrl.
    Limitations

  • No crafted Shopify domain was exercised against the deployed application.

Recommended remediation

Parse and strictly validate the shop hostname against an anchored Shopify-domain rule before generating or fetching URLs, and validate the destination hostname before client navigation.

10. Admin UI Served to All Authenticated Users Regardless of Role

ID: SEC-010
Severity: High
Category: Broken Access Control
Affected code: middleware.ts, src/app/admin/page.tsx

Impact

Any authenticated role can load the admin page shell, exposing waitlist-management controls and internal UI structure even though some backend admin APIs enforce roles.

Technical details

  • The middleware authorizes protected routes with !!token and does not apply a role check to /admin.
  • src/app/admin/page.tsx is a client component with no server-side role check.
  • The waitlist admin API separately enforces owner/admin access, limiting direct API data exposure.

Relevant code

middleware.ts:62-74

        // but we still need to allow the route check
        if (isAuthRoute) {
          return true;
        }

        // All dashboard routes require authentication
        return !!token;
      },
    },
    pages: {
      signIn: '/login',
    },
  }

Validation

Code evidence

  • middleware.ts returns !!token for dashboard routes.

  • The supplied admin page snippet contains no session or role enforcement.
    Runtime evidence

  • An unauthenticated GET to /admin redirected to /login.
    Limitations

  • No authenticated member or analyst session was supplied to verify rendered page access.

Recommended remediation

Enforce owner/admin authorization for the /admin route and page rendering, not only for the backing API endpoints.

11. Invited Users Inserted Without Password Hash — Permanent Authentication Failure

ID: SEC-011
Severity: High
Category: Authentication Failures
Affected code: src/app/api/settings/invite/validate/route.ts

Impact

Users created through invitation validation have no password credential and cannot authenticate through the password-based login flow.

Technical details

  • The invite-validation handler has the bcrypt hashing operation commented out.
  • The users insert omits passwordHash.
  • The authorization callback rejects users when passwordHash is absent.

Relevant code

src/app/api/settings/invite/validate/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { invitations, users, publishers } from '@/lib/db/schema';
import { eq, and, gt } from 'drizzle-orm';

// Validate an invitation token (GET)
export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const token = searchParams.get('token');

    if (!token) {
      return NextResponse.json(
        { error: 'Token is required' },
        { status: 400 }
      );
    }

    // Find the invitation
    const [invitation] = await db
      .select({
        id: invitations.id,
        email: invitations.email,
        name: invitations.name,
        role: invitations.role,
        publisherId: invitations.publisherId,
        status: invitations.status,
        expiresAt: invitations.expiresAt,
      })
      .from(invitations)

Validation

Code evidence

  • src/app/api/settings/invite/validate/route.ts inserts invited users without passwordHash.

  • src/lib/auth/index.ts returns null when user.passwordHash is missing.
    Limitations

  • No invitation was created and tested through login; the failure follows directly from the source paths.

Recommended remediation

Hash the supplied password with the project's password-hashing mechanism and persist passwordHash before creating the invited user.

12. Admin Endpoint Authenticated via Plaintext Query-String Secret

ID: SEC-012
Severity: High
Category: Authentication Failures
Affected code: src/app/api/waitlist/route.ts

Impact

The waitlist administrative endpoint places the authentication secret in URLs, where it may be retained in browser history, access logs, proxy logs, or monitoring systems. Disclosure permits waitlist access.

Technical details

  • The GET handler reads the key query parameter and compares it directly with ADMIN_SECRET_KEY.
  • A separate session-authenticated /api/waitlist/admin endpoint exists.
  • The query-string design exposes the secret outside the request body or authorization context.

Relevant code

src/app/api/waitlist/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { waitlist } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { rateLimit } from '@/lib/rate-limit';
import { createAuditLog } from '@/lib/audit';
import { sendWaitlistNotificationToAdmin, isEmailConfigured } from '@/lib/email';

// POST - Add to waitlist
export async function POST(request: NextRequest) {
  try {
    // Rate limit public submissions (strict - 3 per minute)
    const rateLimitResponse = await rateLimit('public');
    if (rateLimitResponse) return rateLimitResponse;

    const body = await request.json();
    const { email, companyName, companyWebsite, revenueRange, primaryChannel } = body;

    if (!email) {
      return NextResponse.json(
        { error: 'Email is required' },
        { status: 400 }
      );
    }

    // Validate email format
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      return NextResponse.json(
        { error: 'Please enter a valid email address' },

Validation

Code evidence

  • src/app/api/waitlist/route.ts reads ?key= and compares it with process.env.ADMIN_SECRET_KEY.
    Runtime evidence

  • GET /api/waitlist?key= returned 401, showing the endpoint is live and uses this gate.
    Limitations

  • The supplied probe did not test a valid leaked secret or retrieve waitlist records.

Recommended remediation

Remove the query-secret handler and use the existing session-authenticated administrative endpoint with role enforcement.

13. Validate Shopify Pagination Link Header URL Before Following

ID: SEC-013
Severity: High
Category: Broken Access Control
Affected code: src/app/api/connectors/shopify/sync/products/route.ts, src/app/api/connectors/shopify/sync/orders/route.ts

Impact

A malicious Shopify response can redirect pagination to an attacker-controlled or internal URL, causing the server to forward the Shopify access token in a request header.

Technical details

  • Product and order sync handlers extract the next URL from the Shopify Link header.
  • The extracted URL is used without validating its origin.
  • The subsequent fetch includes X-Shopify-Access-Token.

Relevant code

src/app/api/connectors/shopify/sync/products/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { connectors, products } from '@/lib/db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { resolvePublisherWithWriteAccess } from '@/lib/auth';
import { decryptCredentials } from '@/lib/crypto';
import { audit } from '@/lib/audit';
import { rateLimit } from '@/lib/rate-limit';

interface ShopifyProduct {
  id: number;
  title: string;
  body_html: string;
  vendor: string;
  product_type: string;
  tags: string;
  variants: Array<{
    id: number;
    sku: string;
    price: string;
  }>;
  images: Array<{
    src: string;
  }>;
}

// Category mapping for Shopify product types
const categoryMap: Record<string, 'apparel' | 'collectibles' | 'accessories' | 'home' | 'digital' | 'other'> = {
  clothing: 'apparel',
  apparel: 'apparel',

Validation

Code evidence

  • Both sync handlers assign the matched Link URL directly to nextPageUrl.

  • Both then call fetch(nextPageUrl, { headers: { 'X-Shopify-Access-Token': credentials.accessToken } }).
    Limitations

  • A live exploit requires a Shopify shop or upstream response that returns a crafted Link header; no such response was tested.

Recommended remediation

Parse each pagination URL and require it to match the expected Shopify shop origin before following it or forwarding credentials.

14. Rate Limiting and Audit Logging Trust Attacker-Controlled X-Forwarded-For Header

ID: SEC-014
Severity: Medium
Category: Broken Access Control
Affected code: src/lib/rate-limit/index.ts, src/lib/audit/index.ts

Impact

If clients can supply the first X-Forwarded-For value, attackers can rotate the rate-limit key and falsify IP fields in audit records.

Technical details

  • The rate limiter selects the first comma-separated X-Forwarded-For value before x-real-ip.
  • The audit logger uses the same header precedence.
  • Neither path shown validates that X-Forwarded-For was inserted by a trusted proxy.

Relevant code

src/lib/rate-limit/index.ts:106-118

  const headersList = await headers();
  const forwardedFor = headersList.get('x-forwarded-for');
  const realIp = headersList.get('x-real-ip');

  // Get IP from headers (Vercel/Cloudflare)
  const ip = forwardedFor?.split(',')[0]?.trim() || realIp || 'anonymous';
  return `ip:${ip}`;
}

// Rate limit check function
export async function checkRateLimit(
  type: RateLimitType = 'read',

Validation

Code evidence

  • src/lib/rate-limit/index.ts uses forwardedFor?.split(',')[0]?.trim() || realIp.

  • The supplied record reports the same extraction logic in src/lib/audit/index.ts.
    Runtime evidence

  • The supplied runtime check reports that /api/auth/login accepted a request containing X-Forwarded-For.
    Limitations

  • The record does not establish whether the deployment's edge rewrites or strips this header for every request path.

Recommended remediation

Use only proxy-authenticated client-IP metadata, configure the deployment proxy to strip client-supplied forwarding headers, and otherwise avoid using untrusted X-Forwarded-For values.

15. Enforce Server-Side File Size, MIME, and Content Validation on Upload Endpoints

ID: SEC-015
Severity: Medium
Category: Insecure Design
Affected code: src/app/api/products/import/route.ts, src/app/api/sales/import/route.ts, src/app/api/settings/profile/route.ts, src/app/(dashboard)/settings/page.tsx

Impact

Authenticated users can submit arbitrarily large CSV files that are fully buffered in memory, creating memory-exhaustion risk. Avatar values are stored without validation and rendered to team members, enabling tracking URLs and unsafe image schemes.

Technical details

  • CSV import code calls file.text() without a prior size or MIME check.
  • The profile endpoint persists avatar as an arbitrary string.
  • The profile UI renders the value as an image source.
  • The supplied finding applies the same CSV concern to the sales import path.

Relevant code

src/app/api/products/import/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { products } from '@/lib/db/schema';
import { getServerSession, isDemoMode, getDemoPublisherId, requireWriteAccess, canWrite } from '@/lib/auth';

interface CSVProduct {
  name: string;
  sku?: string;
  description?: string;
  category?: string;
  price?: string;
  vendor?: string;
  tags?: string;
  externalId?: string;
}

// Import products from CSV
export async function POST(request: NextRequest) {
  try {
    // SECURITY: Get publisherId from session, not form data
    // SECURITY: Require write access (owner/admin only)
    let publisherId: string;

    const session = await getServerSession();

    if (session?.user?.publisherId) {
      // User is logged in - always check RBAC regardless of demo mode
      if (!canWrite(session.user.role)) {
        return NextResponse.json({ error: 'Write access required' }, { status: 403 });
      }

Validation

Code evidence

  • src/app/api/products/import/route.ts calls file.text() without a file.size check.

  • src/app/api/settings/profile/route.ts assigns avatar without length, scheme, or content validation.
    Runtime evidence

  • The supplied checks reached auth-gated import and profile endpoints and returned 307/405 responses.
    Limitations

  • No oversized upload or unsafe avatar was submitted in the supplied runtime checks.

Recommended remediation

Enforce server-side upload size and type limits, use streaming CSV parsing, validate content, and restrict avatar URLs and formats while rejecting unsafe schemes and SVG where appropriate.

16. Remove Invite Security Token From Server Console Log

ID: SEC-016
Severity: Medium
Category: Security Logging and Alerting Failures
Affected code: src/app/api/settings/invite/route.ts

Impact

Invite tokens are written to server logs in full. Anyone with access to the application log stream can use a still-valid token to accept an invitation, potentially including invitations with administrative roles.

Technical details

  • Invite creation builds an invite URL containing the raw 64-character token.
  • The handler logs the complete URL with console.log.
  • The existing email delivery path does not prevent the token from also entering logs.

Relevant code

src/app/api/settings/invite/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { invitations, users } from '@/lib/db/schema';
import { eq, and, gt } from 'drizzle-orm';
import { getServerSession, isDemoMode, getDemoPublisherId } from '@/lib/auth';
import crypto from 'crypto';

// Generate a secure random token
function generateToken(): string {
  return crypto.randomBytes(32).toString('hex');
}

// Create a new invitation
export async function POST(request: NextRequest) {
  try {
    // SECURITY: Get session and verify admin role
    let publisherId: string;
    let inviterId: string | null = null;

    if (isDemoMode()) {
      publisherId = getDemoPublisherId();
    } else {
      const session = await getServerSession();
      if (!session?.user?.publisherId) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
      }

      // Check if user has permission to invite (owner or admin)
      const userRole = session.user.role;
      if (!['owner', 'admin'].includes(userRole)) {

Validation

Code evidence

  • src/app/api/settings/invite/route.ts logs Invite URL for ${email}: ${inviteUrl}.

  • The logged inviteUrl embeds the raw invitation token.
    Runtime evidence

  • The supplied endpoint check returned a 307 authentication redirect; logging occurs server-side after an authorized request.
    Limitations

  • The record does not show whether a specific deployment log provider retained or exposed a token.

Recommended remediation

Remove the full invite URL and token from logs and deliver invitation links through the existing email mechanism without logging secret values.

17. Remove Raw Security Tokens From HTTP Response Bodies

ID: SEC-017
Severity: Low
Category: Security Logging and Alerting Failures
Affected code: src/app/api/settings/invite/route.ts, src/app/api/waitlist/approve/route.ts

Impact

Raw invitation tokens in HTTP responses can be captured by proxies, CDNs, APM systems, browser tooling, or other components that record response bodies. Disclosure allows unauthorized invitation acceptance.

Technical details

  • The settings invite response includes inviteUrl containing the raw token.
  • The waitlist approval response includes inviteToken and inviteLink.
  • Both endpoints are intended for administrative users, but response intermediaries may still record the values.

Relevant code

src/app/api/settings/invite/route.ts:1-30

import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
import { invitations, users } from '@/lib/db/schema';
import { eq, and, gt } from 'drizzle-orm';
import { getServerSession, isDemoMode, getDemoPublisherId } from '@/lib/auth';
import crypto from 'crypto';

// Generate a secure random token
function generateToken(): string {
  return crypto.randomBytes(32).toString('hex');
}

// Create a new invitation
export async function POST(request: NextRequest) {
  try {
    // SECURITY: Get session and verify admin role
    let publisherId: string;
    let inviterId: string | null = null;

    if (isDemoMode()) {
      publisherId = getDemoPublisherId();
    } else {
      const session = await getServerSession();
      if (!session?.user?.publisherId) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
      }

      // Check if user has permission to invite (owner or admin)
      const userRole = session.user.role;
      if (!['owner', 'admin'].includes(userRole)) {

Validation

Code evidence

  • src/app/api/settings/invite/route.ts returns inviteUrl containing the token.

  • src/app/api/waitlist/approve/route.ts returns inviteToken and inviteLink.
    Runtime evidence

  • The supplied checks report both endpoints reject unauthenticated requests with 401.
    Limitations

  • No proxy, CDN, or APM capture was demonstrated.

Recommended remediation

Return only non-sensitive status information such as emailSent, and do not include raw tokens or invitation URLs in production responses.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions