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:
affaan-m
2026-09-27 23:36:51 -04:00
2 changed files with 895 additions and 81 deletions
+141 -10
View File
@@ -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