mirror of
https://github.com/mattermost/mattermost.git
synced 2026-11-03 12:48:37 -06:00
* docs: self-host fonts, images, and OpenAPI icons; block build egress Stop hotlinking GitHub/user-images and Google Fonts on the docs site, replace embedded video iframes with links, and fail CI when a build would fetch off-origin resources (closed allowlist for the security-updates feed). Co-authored-by: Cursor <cursoragent@cursor.com> * docs: address CodeRabbit egress review findings Scan an offline (no-Algolia) rebuild in Docs CI, tighten ATTR/getAttr boundaries, flag remote template-literal prefixes before ${, and add alt text for the self-hosted plugin-management image. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: replace egress fixture tree with in-memory unit tests Keep scanner coverage without committing dozens of tiny HTML/CSS/JS fixture files. scanSources takes string maps; CLI smoke uses a temp dir. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: keep YouTube/Wistia embeds on hosted site Restore in-page players for the public docs. Allowlist those hosts in check-egress; the offline packager strips them for airgap. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: simplify hosted-embed allowlist to base domains Co-authored-by: Cursor <cursoragent@cursor.com> * docs: tighten hosted-embed allowlist to player kinds and paths Only iframe/script/img player URLs pass; fetch() and other loads to YouTube/Wistia hosts still fail egress. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: allow Wistia swatch preload in hosted-embed egress check The organize-conversations embed emits link[rel=preload] for the swatch; kind-scoped allowlisting missed it and failed Docs CI. Co-authored-by: Cursor <cursoragent@cursor.com> * docs: use getAttr boundary for script hasSrc check \b matches inside data-src, so the old hasSrc could skip scanning an inline script body. Align with ATTR/getAttr and cover the case in tests. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
572 lines
15 KiB
JavaScript
572 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Fail the build when HTML/CSS/JS would fetch an off-origin resource.
|
|
// Closed allowlist: security-updates feed + docs.mattermost.com meta/canonical.
|
|
|
|
import {readdirSync, readFileSync, statSync} from 'node:fs';
|
|
import {dirname, join, relative, resolve} from 'node:path';
|
|
import {fileURLToPath} from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const DEFAULT_ROOT = resolve(HERE, '..', 'build');
|
|
|
|
export const FEED_URL =
|
|
'https://securityupdates.mattermost.com/security_updates.json';
|
|
export const DOCS_HOST = 'docs.mattermost.com';
|
|
export const EMPTY_BUILD_MESSAGE =
|
|
'docs/site/build is missing or empty; run npm run build first';
|
|
|
|
// Hosted docs keep YouTube/Wistia players; build-offline-bundle.py strips them
|
|
// from the airgap tarball. Allowlisted so check-egress matches the public site.
|
|
const HOSTED_EMBED_BASES = [
|
|
'youtube.com',
|
|
'youtu.be',
|
|
'wistia.com',
|
|
'wistia.net',
|
|
];
|
|
|
|
const TEXT_EXT = new Set(['.html', '.css', '.js']);
|
|
// Attribute-name boundary: \b matches after - and :, so data-src / xlink:href
|
|
// must not count as src / href. Require whitespace, quote, or / before the name.
|
|
const ATTR =
|
|
/(?<=[\s"'\/])(src|href|poster|data|srcset|content)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi;
|
|
const CSS_URL =
|
|
/(?:@import\s+(?:url\s*\()?['"]?(?:https?:)?\/\/[^'")\s]+)|(?:url\(\s*['"]?(?:https?:)?\/\/[^'")\s]+)/gi;
|
|
// Quoted URL, complete template literal, or template with a remote prefix
|
|
// before ${ (e.g. fetch(`https://api.example/${path}`)).
|
|
const JS_FETCH =
|
|
/\b(?:fetch|import)\s*\(\s*(?:['"]((?:https?:)?\/\/[^'"]+)['"]|`((?:https?:)?\/\/(?:(?!\$\{)[^`])+)`|`((?:https?:)?\/\/[^`$]*)\$\{)/g;
|
|
const STYLE_BLOCK = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
|
|
const SCRIPT_BLOCK = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
|
|
const LOAD_RELS = new Set([
|
|
'stylesheet',
|
|
'preload',
|
|
'modulepreload',
|
|
'preconnect',
|
|
'dns-prefetch',
|
|
'prefetch',
|
|
'prerender',
|
|
'icon',
|
|
'apple-touch-icon',
|
|
'mask-icon',
|
|
'manifest',
|
|
]);
|
|
|
|
const JSON_SCRIPT_TYPES = new Set([
|
|
'application/json',
|
|
'application/ld+json',
|
|
]);
|
|
|
|
export function isRemoteUrl(raw) {
|
|
const t = String(raw ?? '').trim();
|
|
if (!t) {
|
|
return false;
|
|
}
|
|
if (
|
|
t.startsWith('#') ||
|
|
t.startsWith('data:') ||
|
|
t.startsWith('blob:') ||
|
|
t.startsWith('mailto:') ||
|
|
t.startsWith('tel:')
|
|
) {
|
|
return false;
|
|
}
|
|
return t.startsWith('https://') || t.startsWith('http://') || t.startsWith('//');
|
|
}
|
|
|
|
export function reportUrl(raw) {
|
|
const t = String(raw).trim();
|
|
return t.startsWith('//') ? `https:${t}` : t;
|
|
}
|
|
|
|
function isAllowedFeed(url) {
|
|
return reportUrl(url) === FEED_URL;
|
|
}
|
|
|
|
function isAllowedDocsMeta(url) {
|
|
const reported = reportUrl(url);
|
|
if (!reported.startsWith('https://')) {
|
|
return false;
|
|
}
|
|
try {
|
|
return new URL(reported).hostname === DOCS_HOST;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isHostedEmbedHost(host) {
|
|
return HOSTED_EMBED_BASES.some(
|
|
(base) => host === base || host.endsWith('.' + base),
|
|
);
|
|
}
|
|
|
|
function isYoutubeHost(host) {
|
|
return host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be' || host.endsWith('.youtu.be');
|
|
}
|
|
|
|
function isWistiaHost(host) {
|
|
return (
|
|
host === 'wistia.com' ||
|
|
host.endsWith('.wistia.com') ||
|
|
host === 'wistia.net' ||
|
|
host.endsWith('.wistia.net')
|
|
);
|
|
}
|
|
|
|
/** Allow only player tags/paths used by hosted docs embeds — not arbitrary host traffic. */
|
|
export function isAllowedHostedEmbed(url, kind) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(reportUrl(url));
|
|
} catch {
|
|
return false;
|
|
}
|
|
const host = parsed.hostname.toLowerCase();
|
|
if (!isHostedEmbedHost(host)) {
|
|
return false;
|
|
}
|
|
const path = parsed.pathname;
|
|
|
|
if (kind === 'iframe[src]') {
|
|
if (isYoutubeHost(host) && path.startsWith('/embed/')) {
|
|
return true;
|
|
}
|
|
if (isWistiaHost(host) && (path.startsWith('/embed/') || path.startsWith('/medias/'))) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (kind === 'script[src]') {
|
|
if (!isWistiaHost(host)) {
|
|
return false;
|
|
}
|
|
return (
|
|
path.startsWith('/assets/external/') ||
|
|
(path.startsWith('/embed/medias/') && path.endsWith('.jsonp'))
|
|
);
|
|
}
|
|
// Wistia MDX embed also emits <link rel=preload> for the swatch image.
|
|
if (kind === 'img[src]' || kind === 'link[rel=preload]') {
|
|
return isWistiaHost(host) && path.startsWith('/embed/medias/');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function lineAt(text, index) {
|
|
return text.slice(0, index).split('\n').length;
|
|
}
|
|
|
|
function attrValue(match) {
|
|
return match[2] ?? match[3] ?? match[4] ?? '';
|
|
}
|
|
|
|
function getAttr(tagOpen, name) {
|
|
const re = new RegExp(
|
|
`(?<=[\\s"'\\/])${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`,
|
|
'i',
|
|
);
|
|
const m = tagOpen.match(re);
|
|
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
}
|
|
|
|
function enclosingTag(html, attrIndex) {
|
|
const start = html.lastIndexOf('<', attrIndex);
|
|
if (start < 0) {
|
|
return null;
|
|
}
|
|
const gt = html.indexOf('>', attrIndex);
|
|
const raw = gt >= 0 ? html.slice(start, gt + 1) : html.slice(start);
|
|
const tagMatch = raw.match(/^<\s*([a-zA-Z][\w:-]*)/);
|
|
if (!tagMatch) {
|
|
return null;
|
|
}
|
|
return {
|
|
tag: tagMatch[1].toLowerCase(),
|
|
rel: getAttr(raw, 'rel'),
|
|
property: getAttr(raw, 'property'),
|
|
name: getAttr(raw, 'name'),
|
|
type: getAttr(raw, 'type'),
|
|
};
|
|
}
|
|
|
|
function srcsetUrls(value) {
|
|
return value
|
|
.split(',')
|
|
.map((part) => part.trim().split(/\s+/)[0])
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function relKind(relRaw) {
|
|
const rels = (relRaw || '').toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
if (rels.includes('canonical')) {
|
|
return 'canonical';
|
|
}
|
|
if (rels.includes('alternate')) {
|
|
return 'skip';
|
|
}
|
|
if (rels.includes('shortcut') && rels.includes('icon')) {
|
|
return 'link[rel=shortcut icon]';
|
|
}
|
|
const hit = rels.find((r) => LOAD_RELS.has(r));
|
|
return hit ? `link[rel=${hit}]` : 'skip';
|
|
}
|
|
|
|
function htmlKind(tag, attr, ctx) {
|
|
const t = tag;
|
|
const a = attr.toLowerCase();
|
|
|
|
if (t === 'a' || t === 'area') {
|
|
return 'skip';
|
|
}
|
|
if (t === 'script' && a === 'src') {
|
|
return 'script[src]';
|
|
}
|
|
if (t === 'link' && a === 'href') {
|
|
return relKind(ctx.rel);
|
|
}
|
|
if (t === 'meta' && a === 'content') {
|
|
const prop = (ctx.property || '').toLowerCase();
|
|
const name = (ctx.name || '').toLowerCase();
|
|
if (prop.startsWith('og:')) {
|
|
return `meta[property=${prop}]`;
|
|
}
|
|
if (name.startsWith('twitter:')) {
|
|
return `meta[name=${name}]`;
|
|
}
|
|
return 'skip';
|
|
}
|
|
if (t === 'img' && (a === 'src' || a === 'srcset')) {
|
|
return `img[${a}]`;
|
|
}
|
|
if (t === 'source' && (a === 'src' || a === 'srcset')) {
|
|
return `source[${a}]`;
|
|
}
|
|
if (t === 'video' && (a === 'src' || a === 'poster')) {
|
|
return `video[${a}]`;
|
|
}
|
|
if (t === 'audio' && a === 'src') {
|
|
return 'audio[src]';
|
|
}
|
|
if (t === 'track' && a === 'src') {
|
|
return 'track[src]';
|
|
}
|
|
if (t === 'iframe' && a === 'src') {
|
|
return 'iframe[src]';
|
|
}
|
|
if (t === 'embed' && a === 'src') {
|
|
return 'embed[src]';
|
|
}
|
|
if (t === 'object' && a === 'data') {
|
|
return 'object[data]';
|
|
}
|
|
return 'skip';
|
|
}
|
|
|
|
function pushViolation(violations, file, line, kind, found) {
|
|
const url = reportUrl(found);
|
|
if (isAllowedFeed(url) || isAllowedHostedEmbed(url, kind)) {
|
|
return;
|
|
}
|
|
violations.push({file, line, kind, found: url});
|
|
}
|
|
|
|
function considerUrl(violations, file, line, kind, raw) {
|
|
if (kind === 'skip' || !isRemoteUrl(raw)) {
|
|
return;
|
|
}
|
|
if (kind === 'canonical' || kind.startsWith('meta[property=og') || kind.startsWith('meta[name=twitter')) {
|
|
if (isAllowedDocsMeta(raw)) {
|
|
return;
|
|
}
|
|
const reportedKind = kind === 'canonical' ? 'link[rel=canonical]' : kind;
|
|
pushViolation(violations, file, line, reportedKind, raw);
|
|
return;
|
|
}
|
|
pushViolation(violations, file, line, kind, raw);
|
|
}
|
|
|
|
function scanHtmlAttrs(html, file, violations) {
|
|
ATTR.lastIndex = 0;
|
|
let match;
|
|
while ((match = ATTR.exec(html))) {
|
|
const ctx = enclosingTag(html, match.index);
|
|
if (!ctx) {
|
|
continue;
|
|
}
|
|
const attr = match[1];
|
|
const kind = htmlKind(ctx.tag, attr, ctx);
|
|
const value = attrValue(match);
|
|
const line = lineAt(html, match.index);
|
|
if (attr.toLowerCase() === 'srcset') {
|
|
for (const candidate of srcsetUrls(value)) {
|
|
considerUrl(violations, file, line, kind, candidate);
|
|
}
|
|
} else {
|
|
considerUrl(violations, file, line, kind, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function cssKind(matchText) {
|
|
return /@import/i.test(matchText) ? 'css @import' : 'css url()';
|
|
}
|
|
|
|
function urlFromCssMatch(text) {
|
|
const found = text.match(/((?:https?:)?\/\/[^'")\s]+)/);
|
|
return found ? found[1] : text;
|
|
}
|
|
|
|
function scanCssIn(text, file, violations, fullFile, bodyStart) {
|
|
CSS_URL.lastIndex = 0;
|
|
let match;
|
|
while ((match = CSS_URL.exec(text))) {
|
|
const raw = urlFromCssMatch(match[0]);
|
|
if (!isRemoteUrl(raw)) {
|
|
continue;
|
|
}
|
|
const index = (bodyStart ?? 0) + match.index;
|
|
const source = fullFile ?? text;
|
|
pushViolation(violations, file, lineAt(source, index), cssKind(match[0]), raw);
|
|
}
|
|
}
|
|
|
|
function scanJs(text, file, violations, fullFile, bodyStart) {
|
|
JS_FETCH.lastIndex = 0;
|
|
let match;
|
|
while ((match = JS_FETCH.exec(text))) {
|
|
const raw = match[1] ?? match[2] ?? match[3];
|
|
if (!isRemoteUrl(raw)) {
|
|
continue;
|
|
}
|
|
const index = (bodyStart ?? 0) + match.index;
|
|
const source = fullFile ?? text;
|
|
const kind = match[0].trimStart().startsWith('import')
|
|
? 'import()'
|
|
: 'fetch()';
|
|
considerUrl(violations, file, lineAt(source, index), kind, raw);
|
|
}
|
|
}
|
|
|
|
function isJsonScript(attrText) {
|
|
const type = (getAttr(`<script ${attrText}>`, 'type') || '').toLowerCase();
|
|
return JSON_SCRIPT_TYPES.has(type);
|
|
}
|
|
|
|
function hasSrc(attrText) {
|
|
// Same attribute boundary as getAttr/ATTR — \b matches inside data-src.
|
|
return getAttr(`<script ${attrText}>`, 'src') != null;
|
|
}
|
|
|
|
function scanHtml(html, file, violations) {
|
|
scanHtmlAttrs(html, file, violations);
|
|
|
|
STYLE_BLOCK.lastIndex = 0;
|
|
let style;
|
|
while ((style = STYLE_BLOCK.exec(html))) {
|
|
const bodyStart = style.index + style[0].indexOf(style[1]);
|
|
scanCssIn(style[1], file, violations, html, bodyStart);
|
|
}
|
|
|
|
SCRIPT_BLOCK.lastIndex = 0;
|
|
let script;
|
|
while ((script = SCRIPT_BLOCK.exec(html))) {
|
|
if (hasSrc(script[1]) || isJsonScript(script[1])) {
|
|
continue;
|
|
}
|
|
const bodyStart = script.index + script[0].indexOf(script[2]);
|
|
scanJs(script[2], file, violations, html, bodyStart);
|
|
}
|
|
}
|
|
|
|
function* walk(dir) {
|
|
let entries;
|
|
try {
|
|
entries = readdirSync(dir, {withFileTypes: true});
|
|
} catch (err) {
|
|
if (err && err.code === 'ENOENT') {
|
|
throw new Error(EMPTY_BUILD_MESSAGE);
|
|
}
|
|
throw err;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.name === 'node_modules') {
|
|
continue;
|
|
}
|
|
const full = join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
yield* walk(full);
|
|
} else if (entry.isFile()) {
|
|
yield full;
|
|
}
|
|
}
|
|
}
|
|
|
|
function extOf(file) {
|
|
const base = file.toLowerCase();
|
|
if (base.endsWith('.html')) {
|
|
return '.html';
|
|
}
|
|
if (base.endsWith('.css')) {
|
|
return '.css';
|
|
}
|
|
if (base.endsWith('.js')) {
|
|
return '.js';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/** Scan in-memory sources. Keys are relative paths; values are file text. */
|
|
export function scanSources({html = {}, css = {}, js = {}} = {}) {
|
|
const violations = [];
|
|
const stats = {html: 0, css: 0, js: 0};
|
|
|
|
for (const [file, text] of Object.entries(html)) {
|
|
stats.html++;
|
|
scanHtml(text, file, violations);
|
|
}
|
|
for (const [file, text] of Object.entries(css)) {
|
|
stats.css++;
|
|
if (text.includes('://') || text.includes('url(//')) {
|
|
scanCssIn(text, file, violations, text, 0);
|
|
}
|
|
}
|
|
for (const [file, text] of Object.entries(js)) {
|
|
stats.js++;
|
|
if (text.includes('fetch(') || text.includes('import(')) {
|
|
scanJs(text, file, violations, text, 0);
|
|
}
|
|
}
|
|
|
|
if (stats.html === 0) {
|
|
throw new Error(EMPTY_BUILD_MESSAGE);
|
|
}
|
|
|
|
violations.stats = stats;
|
|
return violations;
|
|
}
|
|
|
|
export function scanBuild(root) {
|
|
const abs = resolve(root);
|
|
try {
|
|
if (!statSync(abs).isDirectory()) {
|
|
throw new Error(EMPTY_BUILD_MESSAGE);
|
|
}
|
|
} catch (err) {
|
|
if (err && err.message === EMPTY_BUILD_MESSAGE) {
|
|
throw err;
|
|
}
|
|
if (err && err.code === 'ENOENT') {
|
|
throw new Error(EMPTY_BUILD_MESSAGE);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const html = {};
|
|
const css = {};
|
|
const js = {};
|
|
|
|
for (const file of walk(abs)) {
|
|
if (file.endsWith('.map')) {
|
|
continue;
|
|
}
|
|
const ext = extOf(file);
|
|
if (!TEXT_EXT.has(ext)) {
|
|
continue;
|
|
}
|
|
const rel = relative(abs, file).split('\\').join('/');
|
|
const text = readFileSync(file, 'utf8');
|
|
if (ext === '.html') {
|
|
html[rel] = text;
|
|
} else if (ext === '.css') {
|
|
css[rel] = text;
|
|
} else {
|
|
js[rel] = text;
|
|
}
|
|
}
|
|
|
|
return scanSources({html, css, js});
|
|
}
|
|
|
|
function fixHint(kind, found) {
|
|
if (
|
|
kind.includes('preconnect') ||
|
|
found.includes('fonts.googleapis.com') ||
|
|
found.includes('fonts.gstatic.com')
|
|
) {
|
|
return (
|
|
'Fonts are already self-hosted in src/css/fonts.css; drop the tag\n' +
|
|
' from docusaurus.config.ts stylesheets/headTags.'
|
|
);
|
|
}
|
|
if (kind === 'fetch()' || kind === 'import()') {
|
|
return (
|
|
'Remove the runtime fetch or, if it is the security bulletin, use\n' +
|
|
` exactly ${FEED_URL}`
|
|
);
|
|
}
|
|
return 'Self-host the asset or remove the tag.';
|
|
}
|
|
|
|
export function formatReport(rootLabel, violations) {
|
|
const {html, css, js} = violations.stats;
|
|
if (violations.length === 0) {
|
|
return `[check-egress] ok: ${html} html, ${css} css, ${js} js, 0 external loads\n`;
|
|
}
|
|
|
|
const lines = [
|
|
`[check-egress] ${violations.length} external resource(s) in ${rootLabel} ` +
|
|
`(scanned ${html} html, ${css} css, ${js} js).`,
|
|
'',
|
|
'A browser would fetch these. Self-host the asset or remove the tag.',
|
|
'Do not add allowlist entries without a design review.',
|
|
'',
|
|
];
|
|
|
|
for (const v of violations) {
|
|
lines.push(` ${v.file}:${v.line}`);
|
|
lines.push(` kind: ${v.kind}`);
|
|
lines.push(` found: ${v.found}`);
|
|
lines.push(` fix: ${fixHint(v.kind, v.found)}`);
|
|
lines.push('');
|
|
}
|
|
|
|
lines.push('Allowlisted (closed):');
|
|
lines.push(` - fetch ${FEED_URL}`);
|
|
lines.push(
|
|
` - <link rel=canonical> and og:*/twitter:* meta on https://${DOCS_HOST}`,
|
|
);
|
|
lines.push(' - <a href> / <area href> (never fetched; includes editUrl)');
|
|
lines.push(
|
|
' - YouTube/Wistia embeds (hosted site only; stripped in offline packaging)',
|
|
);
|
|
lines.push('');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
export function main(argv) {
|
|
const root = argv[0] ? resolve(argv[0]) : DEFAULT_ROOT;
|
|
const label = argv[0] ? relative(process.cwd(), root) || root : 'docs/site/build';
|
|
let violations;
|
|
try {
|
|
violations = scanBuild(root);
|
|
} catch (err) {
|
|
console.error(`[check-egress] ${err.message}`);
|
|
return 1;
|
|
}
|
|
const report = formatReport(label, violations);
|
|
if (violations.length > 0) {
|
|
console.error(report.replace(/\n$/, ''));
|
|
return 1;
|
|
}
|
|
process.stdout.write(report);
|
|
return 0;
|
|
}
|
|
|
|
const isMain =
|
|
process.argv[1] &&
|
|
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
if (isMain) {
|
|
process.exit(main(process.argv.slice(2)));
|
|
}
|