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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user