Skip to content
Merged
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
40 changes: 40 additions & 0 deletions apps/control-plane/src/services/__tests__/shell-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,44 @@ describe('ShellExecutor', () => {
expect(executor.history[0].options).toEqual({ cwd: '/tmp' })
})
})

describe('execFile', () => {
test('given execFile is called on mock executor, should return stdout, stderr, and exitCode', async () => {
const executor = createMockExecutor([
{ stdout: 'hello\n', stderr: '', exitCode: 0 },
])

const result = await executor.execFile('echo', ['hello'])

expect(result).toEqual({ stdout: 'hello\n', stderr: '', exitCode: 0 })
})

test('given execFile is called, should record command and args in history', async () => {
const executor = createMockExecutor([
{ stdout: '', stderr: '', exitCode: 0 },
])

await executor.execFile('docker', ['compose', 'up', '-d'])

expect(executor.history).toHaveLength(1)
expect(executor.history[0].command).toBe('docker')
expect(executor.history[0].args).toEqual(['compose', 'up', '-d'])
})

test('given createShellExecutor, should return an object with execFile method', () => {
const executor = createShellExecutor()

expect(typeof executor.execFile).toBe('function')
})

test('given execFile with options, should record options in history', async () => {
const executor = createMockExecutor([
{ stdout: '', stderr: '', exitCode: 0 },
])

await executor.execFile('ls', ['-la'], { cwd: '/tmp' })

expect(executor.history[0].options).toEqual({ cwd: '/tmp' })
})
})
})
29 changes: 29 additions & 0 deletions apps/control-plane/src/services/__tests__/upgrade-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,35 @@ describe('UpgradeService', () => {
})
})

describe('slug validation', () => {
test('given a tenant with shell metacharacters in slug, should record upgrade_failed', async () => {
const deps = makeDeps()
;(deps.repo.listActiveTenants as ReturnType<typeof vi.fn>).mockResolvedValue([
makeTenant({ id: 't1', slug: 'acme; rm -rf /' }),
])
const service = createUpgradeService(deps)

const result = await service.upgradeAll({ imageTag: 'v2.0.0' })

expect(result.failed).toHaveLength(1)
expect(result.failed[0].error).toContain('Invalid slug')
expect(deps.executor.exec).not.toHaveBeenCalled()
})

test('given a tenant with valid slug from database, should proceed with upgrade', async () => {
const deps = makeDeps()
;(deps.repo.listActiveTenants as ReturnType<typeof vi.fn>).mockResolvedValue([
makeTenant({ id: 't1', slug: 'valid-tenant' }),
])
const service = createUpgradeService(deps)

const result = await service.upgradeAll({ imageTag: 'v2.0.0' })

expect(result.succeeded).toHaveLength(1)
expect(deps.executor.exec).toHaveBeenCalled()
})
})

describe('dry run', () => {
test('given dry-run, should return plan without calling any docker commands', async () => {
const deps = makeDeps()
Expand Down
40 changes: 39 additions & 1 deletion apps/control-plane/src/services/shell-executor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { exec as cpExec } from 'child_process'
import { exec as cpExec, execFile as cpExecFile } from 'child_process'

export type ExecResult = {
stdout: string
Expand All @@ -14,12 +14,14 @@ export type ExecOptions = {

export type HistoryEntry = {
command: string
args?: string[]
exitCode: number
options?: ExecOptions
}

export interface ShellExecutor {
exec(command: string, options?: ExecOptions): Promise<ExecResult>
execFile(program: string, args: string[], options?: ExecOptions): Promise<ExecResult>
history: HistoryEntry[]
}

Expand All @@ -43,6 +45,21 @@ export function createShellExecutor(): ShellExecutor {
})
})
},
async execFile(program: string, args: string[], options: ExecOptions = {}): Promise<ExecResult> {
return new Promise((resolve) => {
cpExecFile(program, args, {
cwd: options.cwd,
timeout: options.timeout,
env: options.env ? { ...process.env, ...options.env } : undefined,
}, (error, stdout, stderr) => {
const exitCode = error ? (error.code as number ?? 1) : 0
const entry: HistoryEntry = { command: program, args, exitCode }
if (Object.keys(options).length > 0) entry.options = options
history.push(entry)
resolve({ stdout, stderr, exitCode })
})
})
},
}
}

Expand Down Expand Up @@ -75,5 +92,26 @@ export function createMockExecutor(responses: MockResponse[]): ShellExecutor {
history.push(entry)
return { stdout: response.stdout, stderr: response.stderr, exitCode: response.exitCode }
},
async execFile(program: string, args: string[], options: ExecOptions = {}): Promise<ExecResult> {
const response = responses[callIndex] ?? { stdout: '', stderr: '', exitCode: -1 }
callIndex++

if (response.delay && options.timeout && response.delay > options.timeout) {
const result = { stdout: '', stderr: 'command timed out', exitCode: -1 }
const entry: HistoryEntry = { command: program, args, exitCode: result.exitCode }
if (Object.keys(options).length > 0) entry.options = options
history.push(entry)
return result
}

if (response.delay) {
await new Promise((resolve) => setTimeout(resolve, response.delay))
}

const entry: HistoryEntry = { command: program, args, exitCode: response.exitCode }
if (Object.keys(options).length > 0) entry.options = options
history.push(entry)
return { stdout: response.stdout, stderr: response.stderr, exitCode: response.exitCode }
},
}
}
6 changes: 6 additions & 0 deletions apps/control-plane/src/services/upgrade-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ShellExecutor } from './shell-executor'
import { validateSlug } from '../validation/tenant-validation'

type UpgradeRepo = {
listActiveTenants(): Promise<Array<{ id: string; slug: string; status: string }>>
Expand Down Expand Up @@ -37,6 +38,11 @@ export function createUpgradeService(deps: UpgradeDeps) {
}

async function upgradeTenant(tenant: TenantRef, imageTag: string): Promise<void> {
const slugResult = validateSlug(tenant.slug)
if (!slugResult.valid) {
throw new Error(`Invalid slug: ${slugResult.error}`)
}

const envOpts = { cwd: basePath, env: { IMAGE_TAG: imageTag } }

// Pull new images
Expand Down
5 changes: 3 additions & 2 deletions apps/control-plane/src/validation/tenant-validation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isValidEmail } from '@pagespace/lib/validators'

export type ValidationResult = {
valid: boolean
error?: string
Expand Down Expand Up @@ -37,8 +39,7 @@ export const validateEmail = (email: string): ValidationResult => {
return { valid: false, error: 'Email is required' }
}

const emailPattern = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
if (!emailPattern.test(email)) {
if (!isValidEmail(email)) {
return { valid: false, error: 'Invalid email format' }
}

Expand Down
1 change: 1 addition & 0 deletions apps/control-plane/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@pagespace/lib/validators': path.resolve(__dirname, '../../packages/lib/src/validators/index.ts'),
'@pagespace/lib': path.resolve(__dirname, '../../packages/lib/src/index.ts'),
},
},
Expand Down
10 changes: 9 additions & 1 deletion apps/marketing/src/app/api/contact/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { Resend } from "resend";

// Bounded-quantifier RFC 5322 regex — O(N), no ReDoS risk
const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
function isValidEmail(email: string): boolean {
if (!email || email.length > 254) return false;
if (!EMAIL_PATTERN.test(email)) return false;
return email.slice(email.lastIndexOf("@") + 1).includes(".");
}

const FROM_EMAIL = process.env.FROM_EMAIL || "noreply@pagespace.ai";
const TO_EMAIL = process.env.CONTACT_EMAIL || "hello@pagespace.ai";

Expand Down Expand Up @@ -64,7 +72,7 @@ export async function POST(request: Request) {
if (!name || typeof name !== "string" || name.trim().length === 0 || name.length > 100) {
return Response.json({ error: "Valid name is required (max 100 characters)" }, { status: 400 });
}
if (!email || typeof email !== "string" || !/^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(email)) {
if (!email || typeof email !== "string" || !isValidEmail(email)) {
return Response.json({ error: "Valid email is required" }, { status: 400 });
}
if (!subject || typeof subject !== "string" || subject.trim().length === 0 || subject.length > 200) {
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/app/api/account/__tests__/get-patch-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,19 @@ vi.mock('@/lib/auth', () => ({
isAuthError: vi.fn(),
}));

vi.mock('@pagespace/lib', () => ({
createUserServiceToken: vi.fn(),
deleteAiUsageLogsForUser: vi.fn(),
}));
vi.mock('@pagespace/lib', () => {
const EMAIL_PATTERN = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
return {
isValidEmail: (email: string) => {
if (!email || email.length > 254) return false;
if (!EMAIL_PATTERN.test(email)) return false;
return email.slice(email.lastIndexOf('@') + 1).includes('.');
},
createUserServiceToken: vi.fn(),
deleteAiUsageLogsForUser: vi.fn(),
deleteMonitoringDataForUser: vi.fn(),
};
});

vi.mock('@pagespace/lib/compliance/anonymize', () => ({
createAnonymizedActorEmail: vi.fn(() => 'deleted_user_abc123'),
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/app/api/account/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { users, db, eq } from '@pagespace/db';
import { loggers, accountRepository, activityLogRepository } from '@pagespace/lib/server';
import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth';
import { createUserServiceToken, deleteAiUsageLogsForUser, deleteMonitoringDataForUser, type ServiceScope } from '@pagespace/lib';
import { createUserServiceToken, deleteAiUsageLogsForUser, deleteMonitoringDataForUser, isValidEmail, type ServiceScope } from '@pagespace/lib';
import { createAnonymizedActorEmail } from '@pagespace/lib/compliance/anonymize';
import { getActorInfo, logUserActivity } from '@pagespace/lib/monitoring/activity-logger';

Expand Down Expand Up @@ -57,9 +57,7 @@ export async function PATCH(req: Request) {
return Response.json({ error: 'Name and email are required' }, { status: 400 });
}

// Email validation - use a linear-time regex that prevents ReDoS
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!emailRegex.test(email)) {
if (!isValidEmail(email)) {
return Response.json({ error: 'Invalid email format' }, { status: 400 });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -930,7 +930,7 @@ describe('POST /api/auth/apple/callback', () => {
});

describe('web platform redirect', () => {
it('redirects to returnUrl with auth success and CSRF token', async () => {
it('redirects to returnUrl with auth success without CSRF token in URL', async () => {
const state = createSignedState({ returnUrl: '/dashboard', platform: 'web' });
const request = createCallbackRequest({
id_token: 'valid-token',
Expand All @@ -943,7 +943,7 @@ describe('POST /api/auth/apple/callback', () => {
const location = response.headers.get('Location')!;
expect(location).toContain('/dashboard');
expect(location).toContain('auth=success');
expect(location).toContain('csrfToken=mock-csrf-token');
expect(location).not.toContain('csrfToken');
});

it('sets session cookie for web redirect', async () => {
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/app/api/auth/apple/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,6 @@ export async function POST(req: Request) {

const redirectUrl = new URL(returnUrl, baseUrl);
redirectUrl.searchParams.set('auth', 'success');
redirectUrl.searchParams.set('csrfToken', csrfToken);

const headers = new Headers();
appendSessionCookie(headers, sessionToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe('GET /api/auth/google/callback', () => {
});

describe('session-based authentication', () => {
it('given successful OAuth, should create session and redirect with CSRF token', async () => {
it('given successful OAuth, should create session and redirect without CSRF token in URL', async () => {
const state = createSignedState({
platform: 'web',
returnUrl: '/dashboard',
Expand Down Expand Up @@ -232,8 +232,8 @@ describe('GET /api/auth/google/callback', () => {
expect(response.status).toBe(307);

const location = response.headers.get('location')!;
expect(location).toContain('csrfToken=mock-csrf-token');
expect(location).toContain('auth=success');
expect(location).not.toContain('csrfToken');
});

it('should revoke existing sessions on login (session fixation prevention)', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,10 +306,11 @@ describe('Open Redirect Protection', () => {
expect(response.status).toBe(307);
const location = response.headers.get('location')!;
expect(location).not.toContain('evil.com');
expect(location).toContain('/dashboard?auth=success&csrfToken=mock-csrf-token');
expect(location).toContain('/dashboard?auth=success');
expect(location).not.toContain('csrfToken');
});

it('given safe returnUrl in state, should redirect to that path with CSRF token', async () => {
it('given safe returnUrl in state, should redirect to that path without CSRF token in URL', async () => {
const state = createSignedState({
platform: 'web',
returnUrl: '/dashboard/my-drive',
Expand All @@ -324,7 +325,8 @@ describe('Open Redirect Protection', () => {

expect(response.status).toBe(307);
const location = response.headers.get('location')!;
expect(location).toContain('/dashboard/my-drive?auth=success&csrfToken=mock-csrf-token');
expect(location).toContain('/dashboard/my-drive?auth=success');
expect(location).not.toContain('csrfToken');
});

it('given protocol-relative URL in legacy state, should redirect to /dashboard', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -775,15 +775,15 @@ describe('GET /api/auth/google/callback', () => {
});

describe('web platform redirect', () => {
it('redirects to returnUrl with auth success and CSRF token', async () => {
it('redirects to returnUrl with auth success without CSRF token in URL', async () => {
const request = createCallbackRequest({ code: 'valid-code' });
const response = await GET(request);

expect(response.status).toBe(307);
const location = response.headers.get('Location')!;
expect(location).toContain('/dashboard');
expect(location).toContain('auth=success');
expect(location).toContain('csrfToken=mock-csrf-token');
expect(location).not.toContain('csrfToken');
});

it('sets session cookie for web redirect', async () => {
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/app/api/auth/google/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,6 @@ export async function GET(req: Request) {

const redirectUrl = new URL(returnUrl, baseUrl);
redirectUrl.searchParams.set('auth', 'success');
redirectUrl.searchParams.set('csrfToken', csrfToken);

const headers = new Headers();
appendSessionCookie(headers, sessionToken);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/app/api/internal/contact/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
import { db, contactSubmissions } from '@pagespace/db';
import { secureCompare } from '@pagespace/lib';
import { isValidEmail, secureCompare } from '@pagespace/lib';

export async function POST(request: Request) {
const secret = process.env.INTERNAL_API_SECRET;
Expand Down Expand Up @@ -34,7 +34,7 @@ export async function POST(request: Request) {
if (!name || typeof name !== 'string' || name.trim().length === 0 || name.length > 100) {
return NextResponse.json({ error: 'Valid name is required (max 100 characters)' }, { status: 400 });
}
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(email)) {
if (!email || typeof email !== 'string' || !isValidEmail(email)) {
return NextResponse.json({ error: 'Valid email is required' }, { status: 400 });
}
if (!subject || typeof subject !== 'string' || subject.trim().length === 0 || subject.length > 200) {
Expand Down
Loading
Loading