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