From d4269a1cc8e2c0b90be2a104a240c48f2566b28c Mon Sep 17 00:00:00 2001 From: Iuliia Ivashko Date: Mon, 2 Mar 2026 18:25:55 +0200 Subject: [PATCH] fix: harden terminal auth, skill file access, and sensitive request logging (#575) * fix: enforce terminal websocket authentication on upgrade * fix: block path traversal in skill supporting file APIs * fix: redact voice token request logs * fix: block skill file path traversal --- packages/web/server/index.js | 38 ++++++++++++++++++++-- packages/web/server/lib/opencode/shared.js | 24 +++++++++++--- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 48742dd9..2d5d5927 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1549,6 +1549,19 @@ const resolveProjectDirectory = async (req) => { return { directory: validated.directory, error: null }; }; +const isUnsafeSkillRelativePath = (value) => { + if (typeof value !== 'string' || value.length === 0) { + return true; + } + + const normalized = value.replace(/\\/g, '/'); + if (path.posix.isAbsolute(normalized)) { + return true; + } + + return normalized.split('/').some((segment) => segment === '..'); +}; + const resolveOptionalProjectDirectory = async (req) => { const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null; const queryDirectory = Array.isArray(req.query?.directory) @@ -6852,7 +6865,9 @@ async function main(options = {}) { // Voice token endpoint - returns OpenAI TTS availability status app.post('/api/voice/token', async (req, res) => { - console.log('[Voice] Token request received:', { body: req.body, headers: req.headers['content-type'] }); + console.log('[Voice] Token request received:', { + contentType: req.headers['content-type'] || null, + }); try { const openaiApiKey = process.env.OPENAI_API_KEY; console.log('[Voice] OpenAI API Key present:', !!openaiApiKey); @@ -9132,6 +9147,9 @@ async function main(options = {}) { try { const skillName = req.params.name; const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } const { directory, error } = await resolveProjectDirectory(req); if (!directory) { return res.status(400).json({ error }); @@ -9151,6 +9169,9 @@ async function main(options = {}) { res.json({ path: filePath, content }); } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } console.error('Failed to read skill file:', error); res.status(500).json({ error: 'Failed to read skill file' }); } @@ -9217,6 +9238,9 @@ async function main(options = {}) { try { const skillName = req.params.name; const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } const { content } = req.body; const { directory, error } = await resolveProjectDirectory(req); if (!directory) { @@ -9237,6 +9261,9 @@ async function main(options = {}) { message: `File ${filePath} saved successfully`, }); } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } console.error('Failed to write skill file:', error); res.status(500).json({ error: error.message || 'Failed to write skill file' }); } @@ -9247,6 +9274,9 @@ async function main(options = {}) { try { const skillName = req.params.name; const filePath = decodeURIComponent(req.params.filePath); // Decode URL-encoded path + if (isUnsafeSkillRelativePath(filePath)) { + return res.status(400).json({ error: 'Invalid file path' }); + } const { directory, error } = await resolveProjectDirectory(req); if (!directory) { return res.status(400).json({ error }); @@ -9266,6 +9296,9 @@ async function main(options = {}) { message: `File ${filePath} deleted successfully`, }); } catch (error) { + if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) { + return res.status(403).json({ error: 'Access to file denied' }); + } console.error('Failed to delete skill file:', error); res.status(500).json({ error: error.message || 'Failed to delete skill file' }); } @@ -12650,7 +12683,8 @@ async function main(options = {}) { const handleUpgrade = async () => { try { if (uiAuthController?.enabled) { - const sessionToken = uiAuthController?.ensureSessionToken?.(req, null); + // Must be awaited: this call performs async token verification. + const sessionToken = await uiAuthController?.ensureSessionToken?.(req, null); if (!sessionToken) { rejectWebSocketUpgrade(socket, 401, 'UI authentication required'); return; diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index 881aad2b..aa4000c4 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -437,8 +437,23 @@ function listSkillSupportingFiles(skillDir) { return files; } +function assertPathWithinSkillDir(skillDir, relativePath) { + const root = fs.realpathSync(skillDir); + const target = path.resolve(root, relativePath); + const relative = path.relative(root, target); + const isWithin = relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + + if (!isWithin) { + const error = new Error('Access to file denied'); + error.code = 'EACCES'; + throw error; + } + + return target; +} + function readSkillSupportingFile(skillDir, relativePath) { - const fullPath = path.join(skillDir, relativePath); + const fullPath = assertPathWithinSkillDir(skillDir, relativePath); if (!fs.existsSync(fullPath)) { return null; } @@ -446,18 +461,19 @@ function readSkillSupportingFile(skillDir, relativePath) { } function writeSkillSupportingFile(skillDir, relativePath, content) { - const fullPath = path.join(skillDir, relativePath); + const fullPath = assertPathWithinSkillDir(skillDir, relativePath); const dir = path.dirname(fullPath); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(fullPath, content, 'utf8'); } function deleteSkillSupportingFile(skillDir, relativePath) { - const fullPath = path.join(skillDir, relativePath); + const root = fs.realpathSync(skillDir); + const fullPath = assertPathWithinSkillDir(skillDir, relativePath); if (fs.existsSync(fullPath)) { fs.unlinkSync(fullPath); let parentDir = path.dirname(fullPath); - while (parentDir !== skillDir) { + while (parentDir !== root) { try { const entries = fs.readdirSync(parentDir); if (entries.length === 0) {