fix: harden file previews and downloads
This commit is contained in:
@@ -14,6 +14,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
|
||||
- `POST /api/fs/mkdir`
|
||||
- `GET /api/fs/read`
|
||||
- `GET /api/fs/raw`
|
||||
- `GET /api/fs/serve/:path(*)`
|
||||
- `POST /api/fs/write`
|
||||
- `POST /api/fs/delete`
|
||||
- `POST /api/fs/rename`
|
||||
|
||||
@@ -98,6 +98,38 @@ const createGitCheckIgnoreTimeoutMs = () => {
|
||||
return 2500;
|
||||
};
|
||||
|
||||
const FILE_MIME_MAP = Object.freeze({
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.mjs': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.wasm': 'application/wasm',
|
||||
'.xml': 'application/xml',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.pdf': 'application/pdf',
|
||||
'.csv': 'text/csv',
|
||||
'.woff2': 'font/woff2',
|
||||
'.woff': 'font/woff',
|
||||
'.ttf': 'font/ttf',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.bmp': 'image/bmp',
|
||||
'.avif': 'image/avif',
|
||||
});
|
||||
|
||||
const MAX_SERVE_BYTES = 100 * 1024 * 1024;
|
||||
|
||||
// Only deterministic, side-effect-free git plumbing path queries are cacheable.
|
||||
// Anything outside this allowlist (including any non-git command) runs normally
|
||||
// — we never cache arbitrary exec.
|
||||
@@ -869,6 +901,67 @@ export const registerFsRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get(/^\/api\/fs\/serve\/(.+)$/, async (req, res) => {
|
||||
const rawPath = req.params[0] || '';
|
||||
if (!rawPath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (req.query?.allowOutsideWorkspace === 'true') {
|
||||
return res.status(403).json({ error: 'allowOutsideWorkspace is not permitted for this endpoint' });
|
||||
}
|
||||
|
||||
const filePath = path.resolve('/', rawPath);
|
||||
const resolved = await resolveReadPathFromContext({
|
||||
req,
|
||||
targetPath: filePath,
|
||||
resolveProjectDirectory,
|
||||
path,
|
||||
os,
|
||||
normalizeDirectoryPath,
|
||||
openchamberUserConfigRoot,
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return res.status(400).json({ error: resolved.error });
|
||||
}
|
||||
|
||||
const [canonicalPath, canonicalBase] = await Promise.all([
|
||||
fsPromises.realpath(resolved.resolved),
|
||||
fsPromises.realpath(resolved.base).catch(() => path.resolve(resolved.base)),
|
||||
]);
|
||||
|
||||
if (!isPathWithinRoot(canonicalPath, canonicalBase, path, os)) {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
|
||||
const stats = await fsPromises.stat(canonicalPath);
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({ error: 'Specified path is not a file' });
|
||||
}
|
||||
if (stats.size > MAX_SERVE_BYTES) {
|
||||
return res.status(413).json({ error: 'File too large to serve' });
|
||||
}
|
||||
|
||||
const ext = path.extname(canonicalPath).toLowerCase();
|
||||
const mimeType = FILE_MIME_MAP[ext] || 'application/octet-stream';
|
||||
const content = await fsPromises.readFile(canonicalPath);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
return res.type(mimeType).send(content);
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
console.error('Failed to serve file:', error);
|
||||
return res.status(500).json({ error: (error && error.message) || 'Failed to serve file' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/fs/write', async (req, res) => {
|
||||
const { path: filePath, content } = req.body || {};
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
|
||||
@@ -270,14 +270,19 @@ const getUrlAuthTokenFromRequest = (req) => {
|
||||
};
|
||||
|
||||
const getRequestPathname = (req) => {
|
||||
if (typeof req?.path === 'string' && req.path) return req.path;
|
||||
const rawUrl = req?.originalUrl || req?.url;
|
||||
if (typeof rawUrl !== 'string' || !rawUrl) return '';
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').pathname;
|
||||
} catch {
|
||||
return '';
|
||||
if (typeof rawUrl === 'string' && rawUrl) {
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').pathname;
|
||||
} catch {
|
||||
// Fall through to Express' derived path fields.
|
||||
}
|
||||
}
|
||||
if (typeof req?.baseUrl === 'string' && req.baseUrl && typeof req?.path === 'string' && req.path) {
|
||||
return `${req.baseUrl}${req.path}`.replace(/\/+/g, '/');
|
||||
}
|
||||
if (typeof req?.path === 'string' && req.path) return req.path;
|
||||
return '';
|
||||
};
|
||||
|
||||
const isWebSocketUpgrade = (req) => {
|
||||
@@ -292,6 +297,8 @@ const isUrlAuthReadableHttpPath = (pathname) => {
|
||||
|| pathname === '/api/openchamber/events'
|
||||
|| pathname === '/api/notifications/stream'
|
||||
|| pathname === '/api/fs/raw'
|
||||
|| pathname === '/api/fs/serve'
|
||||
|| pathname.startsWith('/api/fs/serve/')
|
||||
|| pathname.startsWith('/api/preview/proxy/')
|
||||
|| /^\/api\/terminal\/[^/]+\/stream$/.test(pathname)
|
||||
|| /^\/api\/projects\/[^/]+\/icon$/.test(pathname);
|
||||
|
||||
@@ -182,6 +182,37 @@ describe('ui auth client credential seam', () => {
|
||||
expect(await auth.ensureSessionToken(urlReq, urlRes)).toBe('client:device-1');
|
||||
expect(await auth.resolveAuthContext(urlReq, urlRes, { allowUrlToken: false })).toBe(null);
|
||||
|
||||
const serveReq = { method: 'GET', path: '/api/fs/serve/tmp/index.html', url: `/api/fs/serve/tmp/index.html?oc_url_token=${encodeURIComponent(urlToken)}`, headers: {} };
|
||||
const serveRes = createResponse();
|
||||
let serveCalled = false;
|
||||
await auth.requireAuth(serveReq, serveRes, () => {
|
||||
serveCalled = true;
|
||||
});
|
||||
expect(serveCalled).toBe(true);
|
||||
|
||||
const absoluteServeReq = { method: 'GET', path: '/api/fs/serve/Users/test/project/preview-test.html', url: `/api/fs/serve/Users/test/project/preview-test.html?oc_url_token=${encodeURIComponent(urlToken)}`, headers: {} };
|
||||
const absoluteServeRes = createResponse();
|
||||
let absoluteServeCalled = false;
|
||||
await auth.requireAuth(absoluteServeReq, absoluteServeRes, () => {
|
||||
absoluteServeCalled = true;
|
||||
});
|
||||
expect(absoluteServeCalled).toBe(true);
|
||||
|
||||
const mountedServeReq = {
|
||||
method: 'GET',
|
||||
baseUrl: '/api',
|
||||
path: '/fs/serve/Users/test/project/preview-test.html',
|
||||
originalUrl: `/api/fs/serve/Users/test/project/preview-test.html?oc_url_token=${encodeURIComponent(urlToken)}`,
|
||||
url: `/fs/serve/Users/test/project/preview-test.html?oc_url_token=${encodeURIComponent(urlToken)}`,
|
||||
headers: {},
|
||||
};
|
||||
const mountedServeRes = createResponse();
|
||||
let mountedServeCalled = false;
|
||||
await auth.requireAuth(mountedServeReq, mountedServeRes, () => {
|
||||
mountedServeCalled = true;
|
||||
});
|
||||
expect(mountedServeCalled).toBe(true);
|
||||
|
||||
const arbitraryGetReq = { method: 'GET', path: '/api/config/settings', url: `/api/config/settings?oc_url_token=${encodeURIComponent(urlToken)}`, headers: { accept: 'application/json' } };
|
||||
const arbitraryGetRes = createResponse();
|
||||
let arbitraryGetCalled = false;
|
||||
|
||||
@@ -243,12 +243,21 @@ export const createWebFilesAPI = ({ urls }: WebFilesAPIOptions): FilesAPI => ({
|
||||
|
||||
async downloadFile(path: string): Promise<void> {
|
||||
const target = normalizePath(path);
|
||||
const url = urls.rawFile(target, { download: true });
|
||||
const response = await runtimeFetch('/api/fs/raw', {
|
||||
query: { path: target, download: true },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed (${response.status})`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = target.split('/').pop() || 'file';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user