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
This commit is contained in:
Iuliia Ivashko
2026-03-02 18:25:55 +02:00
committed by GitHub
parent a6d87d5348
commit d4269a1cc8
2 changed files with 56 additions and 6 deletions
+36 -2
View File
@@ -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;
+20 -4
View File
@@ -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) {