Skip to content

Commit e21a987

Browse files
committed
Sandbox PPTX generation in subprocess with vm.createContext
AI-generated PptxGenJS code was executed via new Function() in both the server (full Node.js access) and browser (XSS risk). Replace with a dedicated Node.js subprocess (pptx-worker.cjs) that runs user code inside vm.createContext with a null-prototype sandbox — no access to process, require, Buffer, or any Node.js globals. Process-level isolation ensures a vm escape cannot reach the main process or DB. File access is brokered via IPC so the subprocess never touches the database directly, mirroring the isolated-vm worker pattern. Compilation happens lazily at serve time (compilePptxIfNeeded) rather than on write, matching industry practice for source-stored PPTX pipelines. - Add pptx-worker.cjs: sandboxed subprocess worker - Add pptx-vm.ts: orchestration, IPC bridge, file brokering - Add /api/workspaces/[id]/pptx/preview: REST-correct preview endpoint - Update serve route: compile pptxgenjs source to binary on demand - Update workspace-file.ts: remove unsafe new Function(), store source only - Update next.config.ts: include pptxgenjs in outputFileTracingIncludes - Update trigger.config.ts: add pptx-worker.cjs and pptxgenjs to build
1 parent 0b3000a commit e21a987

7 files changed

Lines changed: 360 additions & 78 deletions

File tree

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createLogger } from '@sim/logger'
33
import type { NextRequest } from 'next/server'
44
import { NextResponse } from 'next/server'
55
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6-
import { generatePptxFromCode } from '@/lib/copilot/tools/server/files/workspace-file'
6+
import { generatePptxFromCode } from '@/lib/execution/pptx-vm'
77
import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
88
import type { StorageContext } from '@/lib/uploads/config'
99
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -47,10 +47,6 @@ function stripStorageKeyPrefix(segment: string): string {
4747
return STORAGE_KEY_PREFIX_RE.test(segment) ? segment.replace(STORAGE_KEY_PREFIX_RE, '') : segment
4848
}
4949

50-
function getWorkspaceIdForCompile(key: string): string | undefined {
51-
return parseWorkspaceFileKey(key) ?? undefined
52-
}
53-
5450
export async function GET(
5551
request: NextRequest,
5652
{ params }: { params: Promise<{ path: string[] }> }
@@ -143,7 +139,7 @@ async function handleLocalFile(
143139
const rawBuffer = await readFile(filePath)
144140
const segment = filename.split('/').pop() || filename
145141
const displayName = stripStorageKeyPrefix(segment)
146-
const workspaceId = getWorkspaceIdForCompile(filename)
142+
const workspaceId = parseWorkspaceFileKey(filename) ?? undefined
147143
const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded(
148144
rawBuffer,
149145
displayName,
@@ -208,7 +204,7 @@ async function handleCloudProxy(
208204

209205
const segment = cloudKey.split('/').pop() || 'download'
210206
const displayName = stripStorageKeyPrefix(segment)
211-
const workspaceId = getWorkspaceIdForCompile(cloudKey)
207+
const workspaceId = parseWorkspaceFileKey(cloudKey) ?? undefined
212208
const { buffer: fileBuffer, contentType } = await compilePptxIfNeeded(
213209
rawBuffer,
214210
displayName,
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { getSession } from '@/lib/auth'
4+
import { generatePptxFromCode } from '@/lib/execution/pptx-vm'
5+
import { verifyWorkspaceMembership } from '@/app/api/workflows/utils'
6+
7+
export const dynamic = 'force-dynamic'
8+
export const runtime = 'nodejs'
9+
10+
const logger = createLogger('PptxPreviewAPI')
11+
12+
/**
13+
* POST /api/workspaces/[id]/pptx/preview
14+
* Compile PptxGenJS source code and return the binary PPTX for streaming preview.
15+
*/
16+
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
17+
const { id: workspaceId } = await params
18+
19+
try {
20+
const session = await getSession()
21+
if (!session?.user?.id) {
22+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const membership = await verifyWorkspaceMembership(session.user.id, workspaceId)
26+
if (!membership) {
27+
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
28+
}
29+
30+
const body = await req.json()
31+
const { code } = body as { code?: string }
32+
33+
if (typeof code !== 'string' || code.trim().length === 0) {
34+
return NextResponse.json({ error: 'code is required' }, { status: 400 })
35+
}
36+
37+
const buffer = await generatePptxFromCode(code, workspaceId)
38+
39+
return new NextResponse(buffer, {
40+
status: 200,
41+
headers: {
42+
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
43+
'Content-Length': String(buffer.length),
44+
'Cache-Control': 'private, no-store',
45+
},
46+
})
47+
} catch (err) {
48+
const message = err instanceof Error ? err.message : 'PPTX generation failed'
49+
logger.error('PPTX preview generation failed', { error: message, workspaceId })
50+
return NextResponse.json({ error: message }, { status: 500 })
51+
}
52+
}

apps/sim/lib/copilot/tools/server/files/workspace-file.ts

Lines changed: 5 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createLogger } from '@sim/logger'
2-
import PptxGenJS from 'pptxgenjs'
32
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
43
import type { WorkspaceFileArgs, WorkspaceFileResult } from '@/lib/copilot/tools/shared/schemas'
54
import {
@@ -13,7 +12,6 @@ import {
1312

1413
const logger = createLogger('WorkspaceFileServerTool')
1514

16-
const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
1715
const PPTX_SOURCE_MIME = 'text/x-pptxgenjs'
1816

1917
const EXT_TO_MIME: Record<string, string> = {
@@ -22,24 +20,6 @@ const EXT_TO_MIME: Record<string, string> = {
2220
'.html': 'text/html',
2321
'.json': 'application/json',
2422
'.csv': 'text/csv',
25-
'.pptx': PPTX_MIME,
26-
}
27-
28-
export async function generatePptxFromCode(code: string, workspaceId: string): Promise<Buffer> {
29-
const pptx = new PptxGenJS()
30-
31-
async function getFileBase64(fileId: string): Promise<string> {
32-
const record = await getWorkspaceFile(workspaceId, fileId)
33-
if (!record) throw new Error(`File not found: ${fileId}`)
34-
const buffer = await downloadWsFile(record)
35-
const mime = record.type || 'image/png'
36-
return `data:${mime};base64,${buffer.toString('base64')}`
37-
}
38-
39-
const fn = new Function('pptx', 'getFileBase64', `return (async () => { ${code} })()`)
40-
await fn(pptx, getFileBase64)
41-
const output = await pptx.write({ outputType: 'nodebuffer' })
42-
return output as Buffer
4323
}
4424

4525
function inferContentType(fileName: string, explicitType?: string): string {
@@ -81,25 +61,9 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
8161
return { success: false, message: 'content is required for write operation' }
8262
}
8363

84-
const isPptx = fileName.toLowerCase().endsWith('.pptx')
85-
let contentType: string
86-
87-
if (isPptx) {
88-
// Validate the code compiles before storing
89-
try {
90-
await generatePptxFromCode(content, workspaceId)
91-
} catch (err) {
92-
const msg = err instanceof Error ? err.message : String(err)
93-
logger.error('PPTX code validation failed', { error: msg, fileName })
94-
return {
95-
success: false,
96-
message: `PPTX generation failed: ${msg}. Fix the pptxgenjs code and retry.`,
97-
}
98-
}
99-
contentType = PPTX_SOURCE_MIME
100-
} else {
101-
contentType = inferContentType(fileName, explicitType)
102-
}
64+
const contentType = fileName.toLowerCase().endsWith('.pptx')
65+
? PPTX_SOURCE_MIME
66+
: inferContentType(fileName, explicitType)
10367

10468
const fileBuffer = Buffer.from(content, 'utf-8')
10569

@@ -148,27 +112,14 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
148112
return { success: false, message: `File with ID "${fileId}" not found` }
149113
}
150114

151-
const isPptxUpdate = fileRecord.name?.toLowerCase().endsWith('.pptx')
152-
if (isPptxUpdate) {
153-
try {
154-
await generatePptxFromCode(content, workspaceId)
155-
} catch (err) {
156-
const msg = err instanceof Error ? err.message : String(err)
157-
return {
158-
success: false,
159-
message: `PPTX generation failed: ${msg}. Fix the pptxgenjs code and retry.`,
160-
}
161-
}
162-
}
163-
164115
const fileBuffer = Buffer.from(content, 'utf-8')
165116

166117
await updateWorkspaceFileContent(
167118
workspaceId,
168119
fileId,
169120
context.userId,
170121
fileBuffer,
171-
isPptxUpdate ? PPTX_SOURCE_MIME : undefined
122+
fileRecord.name?.toLowerCase().endsWith('.pptx') ? PPTX_SOURCE_MIME : undefined
172123
)
173124

174125
logger.info('Workspace file updated via copilot', {
@@ -280,26 +231,13 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
280231
content = content.slice(0, idx) + edit.replace + content.slice(idx + edit.search.length)
281232
}
282233

283-
const isPptxPatch = fileRecord.name?.toLowerCase().endsWith('.pptx')
284-
if (isPptxPatch) {
285-
try {
286-
await generatePptxFromCode(content, workspaceId)
287-
} catch (err) {
288-
const msg = err instanceof Error ? err.message : String(err)
289-
return {
290-
success: false,
291-
message: `Patched PPTX code failed to compile: ${msg}. Fix the edits and retry.`,
292-
}
293-
}
294-
}
295-
296234
const patchedBuffer = Buffer.from(content, 'utf-8')
297235
await updateWorkspaceFileContent(
298236
workspaceId,
299237
fileId,
300238
context.userId,
301239
patchedBuffer,
302-
isPptxPatch ? PPTX_SOURCE_MIME : undefined
240+
fileRecord.name?.toLowerCase().endsWith('.pptx') ? PPTX_SOURCE_MIME : undefined
303241
)
304242

305243
logger.info('Workspace file patched via copilot', {

apps/sim/lib/execution/pptx-vm.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Sandboxed PPTX generation via subprocess.
3+
*
4+
* Mirrors the pattern used by isolated-vm.ts: user code runs in a separate
5+
* Node.js child process so that even a vm sandbox escape cannot reach the main
6+
* Next.js process, the database, or any secrets. File access is brokered via
7+
* IPC — the subprocess never touches the database directly.
8+
*/
9+
10+
import { type ChildProcess, spawn } from 'node:child_process'
11+
import fs from 'node:fs'
12+
import path from 'node:path'
13+
import { fileURLToPath } from 'node:url'
14+
import { createLogger } from '@sim/logger'
15+
import {
16+
downloadWorkspaceFile,
17+
getWorkspaceFile,
18+
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
19+
20+
const logger = createLogger('PptxVMExecution')
21+
22+
const WORKER_STARTUP_TIMEOUT_MS = 10_000
23+
const GENERATION_TIMEOUT_MS = 60_000
24+
const MAX_STDERR = 4096
25+
26+
type WorkerMessage =
27+
| { type: 'ready' }
28+
| { type: 'result'; data: string }
29+
| { type: 'error'; message: string }
30+
| { type: 'getFile'; fileReqId: number; fileId: string }
31+
32+
// Resolved once at module load — the path never changes at runtime.
33+
const currentDir = path.dirname(fileURLToPath(import.meta.url))
34+
const WORKER_PATH = (() => {
35+
const candidates = [
36+
path.join(currentDir, 'pptx-worker.cjs'),
37+
path.join(process.cwd(), 'lib', 'execution', 'pptx-worker.cjs'),
38+
]
39+
const found = candidates.find((p) => fs.existsSync(p))
40+
if (!found) throw new Error(`pptx-worker.cjs not found at any of: ${candidates.join(', ')}`)
41+
return found
42+
})()
43+
44+
/**
45+
* Generate a PPTX file by executing AI-generated PptxGenJS code in a sandboxed
46+
* subprocess. File resources referenced by the code are fetched from workspace
47+
* storage by the main process and delivered to the worker via IPC.
48+
*/
49+
export async function generatePptxFromCode(code: string, workspaceId: string): Promise<Buffer> {
50+
return new Promise<Buffer>((resolve, reject) => {
51+
let proc: ChildProcess | null = null
52+
let settled = false
53+
let startupTimer: ReturnType<typeof setTimeout> | null = null
54+
let generationTimer: ReturnType<typeof setTimeout> | null = null
55+
56+
function done(err: Error): void
57+
function done(err: undefined, result: Buffer): void
58+
function done(err: Error | undefined, result?: Buffer): void {
59+
if (settled) return
60+
settled = true
61+
if (startupTimer) clearTimeout(startupTimer)
62+
if (generationTimer) clearTimeout(generationTimer)
63+
try {
64+
proc?.removeAllListeners()
65+
proc?.kill()
66+
} catch {
67+
// Ignore — process may have already exited
68+
}
69+
if (err) reject(err)
70+
else resolve(result as Buffer)
71+
}
72+
73+
try {
74+
proc = spawn('node', [WORKER_PATH], {
75+
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
76+
serialization: 'json',
77+
})
78+
} catch (err) {
79+
done(err instanceof Error ? err : new Error(String(err)))
80+
return
81+
}
82+
83+
let stderrData = ''
84+
proc.stderr?.on('data', (chunk: Buffer) => {
85+
if (stderrData.length < MAX_STDERR) {
86+
stderrData += chunk.toString()
87+
if (stderrData.length > MAX_STDERR) stderrData = stderrData.slice(0, MAX_STDERR)
88+
}
89+
})
90+
91+
startupTimer = setTimeout(() => {
92+
logger.error('PPTX worker failed to start within timeout')
93+
done(new Error('PPTX worker failed to start'))
94+
}, WORKER_STARTUP_TIMEOUT_MS)
95+
96+
proc.on('exit', (code) => {
97+
if (!settled) {
98+
logger.error('PPTX worker exited unexpectedly', { code, stderr: stderrData.slice(0, 500) })
99+
done(new Error(`PPTX worker exited unexpectedly (code ${code})`))
100+
}
101+
})
102+
103+
proc.on('error', (err) => {
104+
logger.error('PPTX worker process error', { error: err.message })
105+
done(err)
106+
})
107+
108+
proc.on('message', (rawMsg: unknown) => {
109+
const msg = rawMsg as WorkerMessage
110+
111+
if (msg.type === 'ready') {
112+
if (startupTimer) {
113+
clearTimeout(startupTimer)
114+
startupTimer = null
115+
}
116+
generationTimer = setTimeout(() => {
117+
logger.error('PPTX generation timed out')
118+
done(new Error('PPTX generation timed out'))
119+
}, GENERATION_TIMEOUT_MS)
120+
proc!.send({ type: 'generate', code })
121+
return
122+
}
123+
124+
if (msg.type === 'result') {
125+
done(undefined, Buffer.from(msg.data, 'base64'))
126+
return
127+
}
128+
129+
if (msg.type === 'error') {
130+
done(new Error(msg.message))
131+
return
132+
}
133+
134+
if (msg.type === 'getFile') {
135+
handleFileRequest(proc!, workspaceId, msg).catch((err) => {
136+
logger.error('Failed to handle file request from PPTX worker', {
137+
fileId: msg.fileId,
138+
error: err instanceof Error ? err.message : String(err),
139+
})
140+
if (proc && !settled) {
141+
try {
142+
proc.send({
143+
type: 'fileResult',
144+
fileReqId: msg.fileReqId,
145+
error: err instanceof Error ? err.message : 'File fetch failed',
146+
})
147+
} catch {
148+
// Ignore — process may have died
149+
}
150+
}
151+
})
152+
}
153+
})
154+
})
155+
}
156+
157+
async function handleFileRequest(
158+
proc: ChildProcess,
159+
workspaceId: string,
160+
msg: Extract<WorkerMessage, { type: 'getFile' }>
161+
): Promise<void> {
162+
const record = await getWorkspaceFile(workspaceId, msg.fileId)
163+
if (!record) {
164+
proc.send({
165+
type: 'fileResult',
166+
fileReqId: msg.fileReqId,
167+
error: `File not found: ${msg.fileId}`,
168+
})
169+
return
170+
}
171+
172+
const buffer = await downloadWorkspaceFile(record)
173+
const mime = record.type || 'image/png'
174+
proc.send({
175+
type: 'fileResult',
176+
fileReqId: msg.fileReqId,
177+
data: `data:${mime};base64,${buffer.toString('base64')}`,
178+
})
179+
}

0 commit comments

Comments
 (0)