mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 19:59:38 +00:00
feat(projects): add workspace path security validation and align github credentials implementation across components
This commit is contained in:
@@ -7,6 +7,147 @@ import { addProjectManually } from '../projects.js';
|
|||||||
|
|
||||||
const router = express.Router();
|
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
|
* Create a new workspace
|
||||||
* POST /api/projects/create-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"' });
|
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
|
// Handle existing workspace
|
||||||
if (workspaceType === 'existing') {
|
if (workspaceType === 'existing') {
|
||||||
@@ -134,12 +284,20 @@ async function getGithubTokenById(tokenId, userId) {
|
|||||||
const { getDatabase } = await import('../database/db.js');
|
const { getDatabase } = await import('../database/db.js');
|
||||||
const db = await getDatabase();
|
const db = await getDatabase();
|
||||||
|
|
||||||
const token = await db.get(
|
const credential = await db.get(
|
||||||
'SELECT * FROM github_tokens WHERE id = ? AND user_id = ? AND is_active = 1',
|
'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
|
||||||
[tokenId, userId]
|
[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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ function ApiKeysSettings() {
|
|||||||
setApiKeys(apiKeysData.apiKeys || []);
|
setApiKeys(apiKeysData.apiKeys || []);
|
||||||
|
|
||||||
// Fetch GitHub tokens
|
// 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}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
const githubData = await githubRes.json();
|
const githubData = await githubRes.json();
|
||||||
setGithubTokens(githubData.tokens || []);
|
setGithubTokens(githubData.credentials || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching settings:', error);
|
console.error('Error fetching settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,15 +108,16 @@ function ApiKeysSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
const res = await fetch('/api/settings/github-tokens', {
|
const res = await fetch('/api/settings/credentials', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
tokenName: newTokenName,
|
credentialName: newTokenName,
|
||||||
githubToken: newGithubToken
|
credentialType: 'github_token',
|
||||||
|
credentialValue: newGithubToken
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,7 +138,7 @@ function ApiKeysSettings() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
await fetch(`/api/settings/github-tokens/${tokenId}`, {
|
await fetch(`/api/settings/credentials/${tokenId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
});
|
||||||
@@ -150,7 +151,7 @@ function ApiKeysSettings() {
|
|||||||
const toggleGithubToken = async (tokenId, isActive) => {
|
const toggleGithubToken = async (tokenId, isActive) => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('auth-token');
|
const token = localStorage.getItem('auth-token');
|
||||||
await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, {
|
await fetch(`/api/settings/credentials/${tokenId}/toggle`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${token}`,
|
'Authorization': `Bearer ${token}`,
|
||||||
@@ -349,7 +350,7 @@ function ApiKeysSettings() {
|
|||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<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">
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
Added: {new Date(token.created_at).toLocaleDateString()}
|
Added: {new Date(token.created_at).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
const [workspacePath, setWorkspacePath] = useState('');
|
const [workspacePath, setWorkspacePath] = useState('');
|
||||||
const [githubUrl, setGithubUrl] = useState('');
|
const [githubUrl, setGithubUrl] = useState('');
|
||||||
const [selectedGithubToken, setSelectedGithubToken] = useState('');
|
const [selectedGithubToken, setSelectedGithubToken] = useState('');
|
||||||
const [useExistingToken, setUseExistingToken] = useState(true);
|
const [tokenMode, setTokenMode] = useState('stored'); // 'stored' | 'new' | 'none'
|
||||||
const [newGithubToken, setNewGithubToken] = useState('');
|
const [newGithubToken, setNewGithubToken] = useState('');
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
@@ -44,10 +44,10 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
const loadGithubTokens = async () => {
|
const loadGithubTokens = async () => {
|
||||||
try {
|
try {
|
||||||
setLoadingTokens(true);
|
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 data = await response.json();
|
||||||
|
|
||||||
const activeTokens = (data.tokens || []).filter(t => t.is_active);
|
const activeTokens = (data.credentials || []).filter(t => t.is_active);
|
||||||
setAvailableTokens(activeTokens);
|
setAvailableTokens(activeTokens);
|
||||||
|
|
||||||
// Auto-select first token if available
|
// Auto-select first token if available
|
||||||
@@ -122,9 +122,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
if (workspaceType === 'new' && githubUrl) {
|
if (workspaceType === 'new' && githubUrl) {
|
||||||
payload.githubUrl = githubUrl.trim();
|
payload.githubUrl = githubUrl.trim();
|
||||||
|
|
||||||
if (useExistingToken && selectedGithubToken) {
|
if (tokenMode === 'stored' && selectedGithubToken) {
|
||||||
payload.githubTokenId = parseInt(selectedGithubToken);
|
payload.githubTokenId = parseInt(selectedGithubToken);
|
||||||
} else if (!useExistingToken && newGithubToken) {
|
} else if (tokenMode === 'new' && newGithubToken) {
|
||||||
payload.newGithubToken = newGithubToken.trim();
|
payload.newGithubToken = newGithubToken.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,9 +364,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
{/* Token Selection Tabs */}
|
{/* Token Selection Tabs */}
|
||||||
<div className="grid grid-cols-3 gap-2 mb-4">
|
<div className="grid grid-cols-3 gap-2 mb-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setUseExistingToken(true)}
|
onClick={() => setTokenMode('stored')}
|
||||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
useExistingToken
|
tokenMode === 'stored'
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
@@ -374,9 +374,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
Stored Token
|
Stored Token
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setUseExistingToken(false)}
|
onClick={() => setTokenMode('new')}
|
||||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
!useExistingToken && (selectedGithubToken || newGithubToken)
|
tokenMode === 'new'
|
||||||
? 'bg-blue-500 text-white'
|
? 'bg-blue-500 text-white'
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
@@ -385,12 +385,12 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setUseExistingToken(true);
|
setTokenMode('none');
|
||||||
setSelectedGithubToken('');
|
setSelectedGithubToken('');
|
||||||
setNewGithubToken('');
|
setNewGithubToken('');
|
||||||
}}
|
}}
|
||||||
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
!selectedGithubToken && !newGithubToken
|
tokenMode === 'none'
|
||||||
? 'bg-green-500 text-white'
|
? 'bg-green-500 text-white'
|
||||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
}`}
|
}`}
|
||||||
@@ -399,7 +399,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{useExistingToken ? (
|
{tokenMode === 'stored' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Select Token
|
Select Token
|
||||||
@@ -412,12 +412,12 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
<option value="">-- Select a token --</option>
|
<option value="">-- Select a token --</option>
|
||||||
{availableTokens.map((token) => (
|
{availableTokens.map((token) => (
|
||||||
<option key={token.id} value={token.id}>
|
<option key={token.id} value={token.id}>
|
||||||
{token.token_name}
|
{token.credential_name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : tokenMode === 'new' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
GitHub Token
|
GitHub Token
|
||||||
@@ -433,7 +433,7 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
This token will be used only for this operation
|
This token will be used only for this operation
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -498,9 +498,9 @@ const ProjectCreationWizard = ({ onClose, onProjectCreated }) => {
|
|||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
|
<span className="text-gray-600 dark:text-gray-400">Authentication:</span>
|
||||||
<span className="text-xs text-gray-900 dark:text-white">
|
<span className="text-xs text-gray-900 dark:text-white">
|
||||||
{useExistingToken && selectedGithubToken
|
{tokenMode === 'stored' && selectedGithubToken
|
||||||
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.token_name || 'Unknown'}`
|
? `Using stored token: ${availableTokens.find(t => t.id.toString() === selectedGithubToken)?.credential_name || 'Unknown'}`
|
||||||
: newGithubToken
|
: tokenMode === 'new' && newGithubToken
|
||||||
? 'Using provided token'
|
? 'Using provided token'
|
||||||
: 'No authentication'}
|
: 'No authentication'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user