mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-29 04:55:12 +02:00
Merge reviewed contributor PR #3241 into backlog batch
Source-PR: https://github.com/affaan-m/ECC/pull/3241
Source-Head: 83f19dee14
Local integration checkpoint; aggregate review and hosted acceptance pending.
This commit is contained in:
@@ -15,7 +15,7 @@ const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
|
||||
const { renderMarkdown } = require('./markdown');
|
||||
const { escapeHtml, renderMarkdown } = require('./markdown');
|
||||
const { artifactSdkJs } = require('./sdk');
|
||||
const {
|
||||
canvasCss,
|
||||
@@ -101,9 +101,21 @@ function sendJson(res, statusCode, payload) {
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
// Artifact pages render inside a sandboxed iframe (no allow-same-origin) and
|
||||
// legitimately run CDN scripts (Mermaid) plus inline loaders, so the default
|
||||
// restrictive CSP cannot apply. A sandbox-only CSP mirrors the iframe
|
||||
// attribute instead: scripts keep working, but the document gets an opaque
|
||||
// origin, which neuters direct-navigation abuse of the loopback API (no CORS
|
||||
// reads, JSON POSTs are preflight-blocked) without changing in-iframe
|
||||
// behavior. A hostile <meta> CSP in a raw HTML artifact can only narrow this
|
||||
// further, never loosen it.
|
||||
const ARTIFACT_CSP = 'sandbox allow-scripts allow-forms allow-popups';
|
||||
|
||||
function sendHtml(res, statusCode, html, { csp = true } = {}) {
|
||||
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
|
||||
if (csp) {
|
||||
if (csp === 'artifact') {
|
||||
headers['content-security-policy'] = ARTIFACT_CSP;
|
||||
} else if (csp) {
|
||||
headers['content-security-policy'] =
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
|
||||
}
|
||||
@@ -111,6 +123,112 @@ function sendHtml(res, statusCode, html, { csp = true } = {}) {
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
// Sibling assets have an explicit 64 MiB resource limit (413 above it), separate
|
||||
// from request-body limits. Reads use at most the sampled size plus one sentinel.
|
||||
const MAX_ARTIFACT_ASSET_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
function assetReadError(code = 'EARTIFACT_UNSAFE') {
|
||||
return Object.assign(new Error('Artifact sibling read refused'), { code });
|
||||
}
|
||||
|
||||
function sameAssetIdentity(before, after) {
|
||||
return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode;
|
||||
}
|
||||
|
||||
function sameAssetFile(before, after) {
|
||||
return after.isFile() && sameAssetIdentity(before, after) && before.size === after.size
|
||||
&& (before.mtimeNs ?? before.mtimeMs) === (after.mtimeNs ?? after.mtimeMs)
|
||||
&& (before.ctimeNs ?? before.ctimeMs) === (after.ctimeNs ?? after.ctimeMs);
|
||||
}
|
||||
|
||||
function assetByteLength(stats) {
|
||||
if (typeof stats.size !== 'bigint' && !Number.isSafeInteger(stats.size)) throw assetReadError();
|
||||
const size = BigInt(stats.size);
|
||||
if (size < 0n) throw assetReadError();
|
||||
if (size > BigInt(MAX_ARTIFACT_ASSET_BYTES)) throw assetReadError('EARTIFACT_TOO_LARGE');
|
||||
return Number(size);
|
||||
}
|
||||
|
||||
function snapshotAssetChain(realTarget) {
|
||||
const root = path.parse(realTarget).root;
|
||||
const parts = path.relative(root, realTarget).split(path.sep).filter(Boolean);
|
||||
const paths = [root];
|
||||
for (const part of parts) paths.push(path.join(paths[paths.length - 1], part));
|
||||
return paths.map((entryPath, index) => {
|
||||
const stats = fs.lstatSync(entryPath, { bigint: true });
|
||||
const leaf = index === paths.length - 1;
|
||||
if (stats.isSymbolicLink() || !(leaf ? stats.isFile() : stats.isDirectory())) throw assetReadError();
|
||||
return { path: entryPath, stats, leaf };
|
||||
});
|
||||
}
|
||||
|
||||
function revalidateAssetPath({ baseDir, resolved, realBase, realTarget, chain }) {
|
||||
if (fs.realpathSync(baseDir) !== realBase || fs.realpathSync(resolved) !== realTarget) throw assetReadError();
|
||||
for (const entry of chain) {
|
||||
const current = fs.lstatSync(entry.path, { bigint: true });
|
||||
if (current.isSymbolicLink() || !(entry.leaf ? sameAssetFile(entry.stats, current)
|
||||
: current.isDirectory() && sameAssetIdentity(entry.stats, current))) throw assetReadError();
|
||||
}
|
||||
}
|
||||
|
||||
function readAssetDescriptor(fd, size) {
|
||||
const buffer = Buffer.alloc(size + 1);
|
||||
let bytes = 0;
|
||||
while (bytes < buffer.length) {
|
||||
const requested = buffer.length - bytes;
|
||||
const count = fs.readSync(fd, buffer, bytes, requested, bytes);
|
||||
if (!Number.isInteger(count) || count < 0 || count > requested) throw assetReadError();
|
||||
if (count === 0) break;
|
||||
bytes += count;
|
||||
}
|
||||
if (bytes !== size) throw assetReadError();
|
||||
return buffer.subarray(0, bytes);
|
||||
}
|
||||
|
||||
// Canonical ancestors are sampled from the filesystem root, not just realBase.
|
||||
// Revalidation detects observed replacements, but portable pathname operations
|
||||
// are not atomic openat confinement: repeated swap/restore or same-inode content
|
||||
// races can evade observations. This helper covers siblings only, not the direct
|
||||
// session.file read. O_NOFOLLOW protects the leaf only where the flag exists.
|
||||
function readSiblingAsset(baseDir, resolved) {
|
||||
const realBase = fs.realpathSync(baseDir);
|
||||
const realTarget = fs.realpathSync(resolved);
|
||||
const relative = path.relative(realBase, realTarget);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw assetReadError();
|
||||
const chain = snapshotAssetChain(realTarget);
|
||||
const expected = chain[chain.length - 1].stats;
|
||||
const size = assetByteLength(expected);
|
||||
const snapshot = { baseDir, resolved, realBase, realTarget, chain };
|
||||
revalidateAssetPath(snapshot);
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(realTarget, flags);
|
||||
} catch (error) {
|
||||
if (error.code === 'ELOOP' || error.code === 'ENOTDIR') throw assetReadError();
|
||||
throw error;
|
||||
}
|
||||
let primaryError;
|
||||
let data;
|
||||
try {
|
||||
if (!sameAssetFile(expected, fs.fstatSync(fd, { bigint: true }))) throw assetReadError();
|
||||
revalidateAssetPath(snapshot);
|
||||
data = readAssetDescriptor(fd, size);
|
||||
if (!sameAssetFile(expected, fs.fstatSync(fd, { bigint: true }))) throw assetReadError();
|
||||
revalidateAssetPath(snapshot);
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
try { fs.closeSync(fd); } catch (error) {
|
||||
// A close error may mean the fd is already released; never retry it or
|
||||
// replace the primary read/validation failure. Close-only failure refuses.
|
||||
if (!primaryError) primaryError = error;
|
||||
}
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return { data, realTarget };
|
||||
}
|
||||
|
||||
function createPlanCanvasServer({
|
||||
store,
|
||||
host = DEFAULT_HOST,
|
||||
@@ -493,7 +611,7 @@ function createPlanCanvasServer({
|
||||
try {
|
||||
content = fs.readFileSync(session.file, 'utf8');
|
||||
} catch {
|
||||
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${session.file} no longer exists.</p>`, { csp: false });
|
||||
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${escapeHtml(session.file)} no longer exists.</p>`);
|
||||
}
|
||||
const ext = path.extname(session.file).toLowerCase();
|
||||
if (ext === '.md' || ext === '.markdown') {
|
||||
@@ -501,30 +619,43 @@ function createPlanCanvasServer({
|
||||
title: path.basename(session.file),
|
||||
sdkSrc: '/sdk.js'
|
||||
});
|
||||
return sendHtml(res, 200, html, { csp: false });
|
||||
return sendHtml(res, 200, html, { csp: 'artifact' });
|
||||
}
|
||||
const sdkTag = '<script src="/sdk.js"></script>';
|
||||
const injected = content.includes('</body>')
|
||||
? content.replace('</body>', `${sdkTag}\n</body>`)
|
||||
: `${content}\n${sdkTag}`;
|
||||
return sendHtml(res, 200, injected, { csp: false });
|
||||
return sendHtml(res, 200, injected, { csp: 'artifact' });
|
||||
}
|
||||
|
||||
// Sibling assets resolve relative to the artifact's directory and must
|
||||
// stay confined to it.
|
||||
// stay confined to it. The prefix check alone is insufficient: a symlink
|
||||
// inside the directory can point outside it, so the check is repeated
|
||||
// against the real paths and fails closed when they cannot be resolved.
|
||||
const baseDir = path.dirname(session.file);
|
||||
const resolved = path.resolve(baseDir, assetPath);
|
||||
if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
|
||||
return sendJson(res, 403, { error: 'asset path escapes artifact directory' });
|
||||
}
|
||||
let realTarget;
|
||||
let data;
|
||||
try {
|
||||
data = fs.readFileSync(resolved);
|
||||
} catch {
|
||||
({ data, realTarget } = readSiblingAsset(baseDir, resolved));
|
||||
} catch (error) {
|
||||
if (error.code === 'EARTIFACT_TOO_LARGE') return sendJson(res, 413, { error: 'asset too large' });
|
||||
if (error.code === 'EARTIFACT_UNSAFE') return sendJson(res, 403, { error: 'asset changed or unsafe' });
|
||||
return sendJson(res, 404, { error: 'asset not found' });
|
||||
}
|
||||
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
|
||||
// MIME comes from the link/request name first so a symlinked asset keeps
|
||||
// the type the page asked for; the target extension is the fallback.
|
||||
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()]
|
||||
|| CONTENT_TYPES[path.extname(realTarget).toLowerCase()]
|
||||
|| 'application/octet-stream';
|
||||
const headers = { 'content-type': type, 'cache-control': 'no-store' };
|
||||
if (type.startsWith('text/html') || type === 'image/svg+xml') {
|
||||
headers['content-security-policy'] = ARTIFACT_CSP;
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
return res.end(data);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user