diff --git a/server/routes/projects.js b/server/routes/projects.js index 665f094..0414faa 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -7,6 +7,147 @@ import { addProjectManually } from '../projects.js'; const router = express.Router(); +// Configure allowed workspace root (defaults to user's home directory) +const WORKSPACES_ROOT = process.env.WORKSPACES_ROOT || os.homedir(); + +// System-critical paths that should never be used as workspace directories +const FORBIDDEN_PATHS = [ + '/', + '/etc', + '/bin', + '/sbin', + '/usr', + '/dev', + '/proc', + '/sys', + '/var', + '/boot', + '/root', + '/lib', + '/lib64', + '/opt', + '/tmp', + '/run' +]; + +/** + * Validates that a path is safe for workspace operations + * @param {string} requestedPath - The path to validate + * @returns {Promise<{valid: boolean, resolvedPath?: string, error?: string}>} + */ +async function validateWorkspacePath(requestedPath) { + try { + // Resolve to absolute path + let absolutePath = path.resolve(requestedPath); + + // Check if path is a forbidden system directory + const normalizedPath = path.normalize(absolutePath); + if (FORBIDDEN_PATHS.includes(normalizedPath) || normalizedPath === '/') { + return { + valid: false, + error: 'Cannot use system-critical directories as workspace locations' + }; + } + + // Additional check for paths starting with forbidden directories + for (const forbidden of FORBIDDEN_PATHS) { + if (normalizedPath === forbidden || + normalizedPath.startsWith(forbidden + path.sep)) { + // Exception: /var/tmp and similar user-accessible paths might be allowed + // but /var itself and most /var subdirectories should be blocked + if (forbidden === '/var' && + (normalizedPath.startsWith('/var/tmp') || + normalizedPath.startsWith('/var/folders'))) { + continue; // Allow these specific cases + } + + return { + valid: false, + error: `Cannot create workspace in system directory: ${forbidden}` + }; + } + } + + // Try to resolve the real path (following symlinks) + let realPath; + try { + // Check if path exists to resolve real path + await fs.access(absolutePath); + realPath = await fs.realpath(absolutePath); + } catch (error) { + if (error.code === 'ENOENT') { + // Path doesn't exist yet - check parent directory + let parentPath = path.dirname(absolutePath); + try { + const parentRealPath = await fs.realpath(parentPath); + + // Reconstruct the full path with real parent + realPath = path.join(parentRealPath, path.basename(absolutePath)); + } catch (parentError) { + if (parentError.code === 'ENOENT') { + // Parent doesn't exist either - use the absolute path as-is + // We'll validate it's within allowed root + realPath = absolutePath; + } else { + throw parentError; + } + } + } else { + throw error; + } + } + + // Resolve the workspace root to its real path + const resolvedWorkspaceRoot = await fs.realpath(WORKSPACES_ROOT); + + // Ensure the resolved path is contained within the allowed workspace root + if (!realPath.startsWith(resolvedWorkspaceRoot + path.sep) && + realPath !== resolvedWorkspaceRoot) { + return { + valid: false, + error: `Workspace path must be within the allowed workspace root: ${WORKSPACES_ROOT}` + }; + } + + // Additional symlink check for existing paths + try { + await fs.access(absolutePath); + const stats = await fs.lstat(absolutePath); + + if (stats.isSymbolicLink()) { + // Verify symlink target is also within allowed root + const linkTarget = await fs.readlink(absolutePath); + const resolvedTarget = path.resolve(path.dirname(absolutePath), linkTarget); + const realTarget = await fs.realpath(resolvedTarget); + + if (!realTarget.startsWith(resolvedWorkspaceRoot + path.sep) && + realTarget !== resolvedWorkspaceRoot) { + return { + valid: false, + error: 'Symlink target is outside the allowed workspace root' + }; + } + } + } catch (error) { + if (error.code !== 'ENOENT') { + throw error; + } + // Path doesn't exist - that's fine for new workspace creation + } + + return { + valid: true, + resolvedPath: realPath + }; + + } catch (error) { + return { + valid: false, + error: `Path validation failed: ${error.message}` + }; + } +} + /** * Create a new workspace * POST /api/projects/create-workspace @@ -31,7 +172,16 @@ router.post('/create-workspace', async (req, res) => { return res.status(400).json({ error: 'workspaceType must be "existing" or "new"' }); } - const absolutePath = path.resolve(workspacePath); + // Validate path safety before any operations + const validation = await validateWorkspacePath(workspacePath); + if (!validation.valid) { + return res.status(400).json({ + error: 'Invalid workspace path', + details: validation.error + }); + } + + const absolutePath = validation.resolvedPath; // Handle existing workspace if (workspaceType === 'existing') { @@ -134,12 +284,20 @@ async function getGithubTokenById(tokenId, userId) { const { getDatabase } = await import('../database/db.js'); const db = await getDatabase(); - const token = await db.get( - 'SELECT * FROM github_tokens WHERE id = ? AND user_id = ? AND is_active = 1', - [tokenId, userId] + const credential = await db.get( + 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1', + [tokenId, userId, 'github_token'] ); - return token; + // Return in the expected format (github_token field for compatibility) + if (credential) { + return { + ...credential, + github_token: credential.credential_value + }; + } + + return null; } /** diff --git a/src/components/ApiKeysSettings.jsx b/src/components/ApiKeysSettings.jsx index 8a8c25b..c2d427c 100644 --- a/src/components/ApiKeysSettings.jsx +++ b/src/components/ApiKeysSettings.jsx @@ -33,11 +33,11 @@ function ApiKeysSettings() { setApiKeys(apiKeysData.apiKeys || []); // Fetch GitHub tokens - const githubRes = await fetch('/api/settings/github-tokens', { + const githubRes = await fetch('/api/settings/credentials?type=github_token', { headers: { 'Authorization': `Bearer ${token}` } }); const githubData = await githubRes.json(); - setGithubTokens(githubData.tokens || []); + setGithubTokens(githubData.credentials || []); } catch (error) { console.error('Error fetching settings:', error); } finally { @@ -108,15 +108,16 @@ function ApiKeysSettings() { try { const token = localStorage.getItem('auth-token'); - const res = await fetch('/api/settings/github-tokens', { + const res = await fetch('/api/settings/credentials', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ - tokenName: newTokenName, - githubToken: newGithubToken + credentialName: newTokenName, + credentialType: 'github_token', + credentialValue: newGithubToken }) }); @@ -137,7 +138,7 @@ function ApiKeysSettings() { try { const token = localStorage.getItem('auth-token'); - await fetch(`/api/settings/github-tokens/${tokenId}`, { + await fetch(`/api/settings/credentials/${tokenId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); @@ -150,7 +151,7 @@ function ApiKeysSettings() { const toggleGithubToken = async (tokenId, isActive) => { try { const token = localStorage.getItem('auth-token'); - await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, { + await fetch(`/api/settings/credentials/${tokenId}/toggle`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, @@ -349,7 +350,7 @@ function ApiKeysSettings() { className="flex items-center justify-between p-3 border rounded-lg" >
-
{token.token_name}
+
{token.credential_name}
Added: {new Date(token.created_at).toLocaleDateString()}
diff --git a/src/components/ProjectCreationWizard.jsx b/src/components/ProjectCreationWizard.jsx index c85d589..38564dc 100644 --- a/src/components/ProjectCreationWizard.jsx +++ b/src/components/ProjectCreationWizard.jsx @@ -13,7 +13,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const [workspacePath, setWorkspacePath] = useState(''); const [githubUrl, setGithubUrl] = useState(''); const [selectedGithubToken, setSelectedGithubToken] = useState(''); - const [useExistingToken, setUseExistingToken] = useState(true); + const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none' const [newGithubToken, setNewGithubToken] = useState(''); // UI state @@ -44,10 +44,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { const loadGithubTokens = async () => { try { setLoadingTokens(true); - const response = await api.get('/settings/github-tokens'); + const response = await api.get('/settings/credentials?type=github_token'); const data = await response.json(); - const activeTokens = (data.tokens || []).filter(t => t.is_active); + const activeTokens = (data.credentials || []).filter(t => t.is_active); setAvailableTokens(activeTokens); // Auto-select first token if available @@ -122,9 +122,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { if (workspaceType === 'new' && githubUrl) { payload.githubUrl = githubUrl.trim(); - if (useExistingToken && selectedGithubToken) { + if (tokenMode === 'stored' && selectedGithubToken) { payload.githubTokenId = parseInt(selectedGithubToken); - } else if (!useExistingToken && newGithubToken) { + } else if (tokenMode === 'new' && newGithubToken) { payload.newGithubToken = newGithubToken.trim(); } } @@ -364,9 +364,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => { {/* Token Selection Tabs */}
- {useExistingToken ? ( + {tokenMode === 'stored' ? (
- ) : ( + ) : tokenMode === 'new' ? (
- )} + ) : null} ) : (
@@ -498,9 +498,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
Authentication: - {useExistingToken && selectedGithubToken - ? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.token_name || 'Unknown'}` - : newGithubToken + {tokenMode === 'stored' && selectedGithubToken + ? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}` + : tokenMode === 'new' && newGithubToken ? 'Using provided token' : 'No authentication'}