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
13 changes: 7 additions & 6 deletions src/server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
parseUpdateWorkspacePageRequest,
parseUpdateWorkspaceSeedSelectionRequest,
parseWorkspaceId,
parseRunId,
} from './validation.js';
import { startGeneration } from './generation.js';
import { readRunAsset } from './assets.js';
Expand Down Expand Up @@ -747,19 +748,19 @@ export const handleApiRequest: ApiHandler = async (request, response) => {

const runMatch = /^\/api\/runs\/([^/]+)$/.exec(url.pathname);
if (request.method === 'GET' && runMatch) {
sendJson(response, 200, { run: await readRun(decodeURIComponent(runMatch[1])) });
sendJson(response, 200, { run: await readRun(parseRunId(runMatch[1])) });
return true;
}

const eventsMatch = /^\/api\/runs\/([^/]+)\/events$/.exec(url.pathname);
if (request.method === 'GET' && eventsMatch) {
await streamSseRun(decodeURIComponent(eventsMatch[1]), request, response);
await streamSseRun(parseRunId(eventsMatch[1]), request, response);
return true;
}

const assetMatch = /^\/api\/runs\/([^/]+)\/assets\/(.+)$/.exec(url.pathname);
if (request.method === 'GET' && assetMatch) {
const asset = await readRunAsset(decodeURIComponent(assetMatch[1]), decodeURIComponent(assetMatch[2]));
const asset = await readRunAsset(parseRunId(assetMatch[1]), decodeURIComponent(assetMatch[2]));
response.writeHead(200, {
'content-type': asset.contentType,
'cache-control': 'no-store',
Expand All @@ -771,13 +772,13 @@ export const handleApiRequest: ApiHandler = async (request, response) => {

const handoverMatch = /^\/api\/runs\/([^/]+)\/handover$/.exec(url.pathname);
if (request.method === 'POST' && handoverMatch) {
await handleHandover(response, decodeURIComponent(handoverMatch[1]), await readJsonBody(request));
await handleHandover(response, parseRunId(handoverMatch[1]), await readJsonBody(request));
return true;
}

const handoverAssetMatch = /^\/api\/runs\/([^/]+)\/handovers\/([^/]+)\/(handover\.(?:md|html))$/.exec(url.pathname);
if (request.method === 'GET' && handoverAssetMatch) {
const run = await readRun(decodeURIComponent(handoverAssetMatch[1]));
const run = await readRun(parseRunId(handoverAssetMatch[1]));
const designId = decodeURIComponent(handoverAssetMatch[2]);
const asset = handoverAssetMatch[3] as 'handover.md' | 'handover.html';
const handover = run.handovers.find((entry) => entry.designId === designId);
Expand Down Expand Up @@ -812,7 +813,7 @@ export const handleApiRequest: ApiHandler = async (request, response) => {

const nestedHandoverAssetMatch = /^\/api\/runs\/([^/]+)\/handovers\/([^/]+)\/assets\/(.+)$/.exec(url.pathname);
if (request.method === 'GET' && nestedHandoverAssetMatch) {
const run = await readRun(decodeURIComponent(nestedHandoverAssetMatch[1]));
const run = await readRun(parseRunId(nestedHandoverAssetMatch[1]));
const designId = decodeURIComponent(nestedHandoverAssetMatch[2]);
const assetId = decodeURIComponent(nestedHandoverAssetMatch[3]);
const handover = run.handovers.find((entry) => entry.designId === designId);
Expand Down
44 changes: 44 additions & 0 deletions src/server/assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { readRunAsset } from './assets.js';
import { runDir } from './runStore.js';

const testRunId = '11111111-1111-4111-8111-111111111111';
const runDirs: string[] = [];

const createAsset = async (relativePath: string, contents: string): Promise<void> => {
const dir = runDir(testRunId);
runDirs.push(dir);
const absolute = path.join(dir, relativePath);
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, contents, 'utf8');
};

describe('run assets', () => {
afterEach(async () => {
await Promise.all(runDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

it('reads generated assets below the run asset directory', async () => {
await createAsset('assets/design-1.png', 'png-bytes');

await expect(readRunAsset(testRunId, 'assets/design-1.png')).resolves.toMatchObject({
bytes: Buffer.from('png-bytes'),
contentType: 'image/png',
});
});

it('rejects asset paths outside the run asset directory', async () => {
await createAsset('assets/design-1.png', 'png-bytes');

await expect(readRunAsset(testRunId, '../run.json')).rejects.toThrow('A valid run asset path is required.');
await expect(readRunAsset(testRunId, 'assets/../../run.json')).rejects.toThrow('A valid run asset path is required.');
await expect(readRunAsset(testRunId, '/etc/passwd')).rejects.toThrow('A valid run asset path is required.');
});

it('rejects invalid run ids before building filesystem paths', async () => {
await expect(readRunAsset('x', 'assets/design-1.png')).rejects.toThrow('A valid run id is required.');
await expect(readRunAsset('../../../../etc', 'assets/design-1.png')).rejects.toThrow('A valid run id is required.');
});
});
26 changes: 24 additions & 2 deletions src/server/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,35 @@ export const saveImageData = async (
};
};

const resolveRunAssetPath = (runId: string, assetPath: string): { absolute: string; relative: string } => {
const relative = assetPath.trim().replace(/^assets[\\/]+/, '');
const segments = relative.split(/[\\/]+/).filter(Boolean);
if (
!relative
|| path.isAbsolute(relative)
|| path.win32.isAbsolute(relative)
|| segments.some((segment) => segment === '..' || segment === '.')
) {
throw new Error('A valid run asset path is required.');
}

const assetRoot = path.resolve(runDir(runId), 'assets');
const absolute = path.resolve(assetRoot, ...segments);
const assetRootWithSeparator = `${assetRoot}${path.sep}`;
if (absolute !== assetRoot && !absolute.startsWith(assetRootWithSeparator)) {
throw new Error('A valid run asset path is required.');
}

return { absolute, relative: segments.join('/') };
};

export const readRunAsset = async (
runId: string,
assetPath: string,
): Promise<{ bytes: Buffer; contentType: string }> => {
const absolute = path.join(runDir(runId), assetPath);
const { absolute, relative } = resolveRunAssetPath(runId, assetPath);
const bytes = await readFile(absolute);
const extension = path.extname(assetPath).toLowerCase();
const extension = path.extname(relative).toLowerCase();
const contentType = extension === '.jpg' || extension === '.jpeg'
? 'image/jpeg'
: extension === '.webp'
Expand Down
3 changes: 2 additions & 1 deletion src/server/runStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
RunStatus,
} from '../shared/types.js';
import { runsRoot, serverConfig } from './config.js';
import { parseRunId } from './validation.js';

type RunPatch = Partial<Omit<DesignRun, 'id' | 'events' | 'createdAt'>>;

Expand All @@ -24,7 +25,7 @@ const withRunLock = async <T,>(runId: string, operation: () => Promise<T>): Prom
return next;
};

export const runDir = (runId: string): string => path.join(runsRoot, runId);
export const runDir = (runId: string): string => path.join(runsRoot, parseRunId(runId));
export const runJsonPath = (runId: string): string => path.join(runDir(runId), 'run.json');

export const ensureRunsRoot = async (): Promise<void> => {
Expand Down
7 changes: 7 additions & 0 deletions src/server/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
parseDirectCreateHandoverRequest,
parsePlanWorkspacePagesRequest,
parseDesignId,
parseRunId,
} from './validation.js';

describe('validation', () => {
Expand Down Expand Up @@ -78,6 +79,12 @@ describe('validation', () => {
expect(() => parseDesignId('../design-1')).toThrow('A valid designId is required.');
});

it('validates run ids', () => {
expect(parseRunId('11111111-1111-4111-8111-111111111111')).toBe('11111111-1111-4111-8111-111111111111');
expect(() => parseRunId('x')).toThrow('A valid run id is required.');
expect(() => parseRunId('%2e%2e%2f%2e%2e%2fetc')).toThrow('A valid run id is required.');
});

it('parses create workspaces with seed variation count', () => {
expect(parseCreateWorkspaceRequest({
prompt: 'Create UI',
Expand Down
9 changes: 9 additions & 0 deletions src/server/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,15 @@ export const parseDesignId = (value: unknown): string => {
return designId;
};


export const parseRunId = (value: string): string => {
const runId = decodeURIComponent(value).trim();
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(runId)) {
throw new Error('A valid run id is required.');
}
return runId;
};

export const parseWorkspaceId = (value: string): string => {
const workspaceId = decodeURIComponent(value).trim();
if (!/^[a-z0-9-]{8,80}$/i.test(workspaceId)) {
Expand Down