feat(projects): add workspace path security validation and align github credentials implementation across components

This commit is contained in:
simos
2025-11-04 09:29:21 +00:00
parent 0181883c8a
commit 255aed0b01
3 changed files with 190 additions and 31 deletions

View File

@@ -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;
}
/**

View File

@@ -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"
>
<div className="flex-1">
<div className="font-medium">{token.token_name}</div>
<div className="font-medium">{token.credential_name}</div>
<div className="text-xs text-muted-foreground mt-1">
Added: {new Date(token.created_at).toLocaleDateString()}
</div>

View File

@@ -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 */}
<div className="grid grid-cols-3 gap-2 mb-4">
<button
onClick={() => setUseExistingToken(true)}
onClick={() => setTokenMode('stored')}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
useExistingToken
tokenMode === 'stored'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
@@ -374,9 +374,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
Stored Token
</button>
<button
onClick={() => setUseExistingToken(false)}
onClick={() => setTokenMode('new')}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
!useExistingToken && (selectedGithubToken || newGithubToken)
tokenMode === 'new'
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
@@ -385,12 +385,12 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</button>
<button
onClick={() => {
setUseExistingToken(true);
setTokenMode('none');
setSelectedGithubToken('');
setNewGithubToken('');
}}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
!selectedGithubToken && !newGithubToken
tokenMode === 'none'
? 'bg-green-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
@@ -399,7 +399,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
</button>
</div>
{useExistingToken ? (
{tokenMode === 'stored' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Token
@@ -412,12 +412,12 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<option value="">-- Select a token --</option>
{availableTokens.map((token) => (
<option key={token.id} value={token.id}>
{token.token_name}
{token.credential_name}
</option>
))}
</select>
</div>
) : (
) : tokenMode === 'new' ? (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
GitHub Token
@@ -433,7 +433,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
This token will be used only for this operation
</p>
</div>
)}
) : null}
</>
) : (
<div className="space-y-4">
@@ -498,9 +498,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
<div className="flex justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
<span className="text-xs text-gray-900 dark:text-white">
{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'}
</span>