mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-14 00:00:17 +00:00
fix(skills): clear two Snyk Fails and harden the network + supply-chain surface (#1804)
* fix(skills): clear Snyk findings and harden supply-chain surface
Address the security-audit findings on the published skills with no change to
any skill's behaviour.
- media-use: resolve.test.mjs runs resolve.mjs via execFileSync with an argv
array instead of execSync(`node … "${tmp}" …`), removing the command-injection
(CWE-78) sink that drove the Snyk Fail.
- music-to-video: replace dynamic `element.innerHTML = <var>` with a setSvg()
helper (DOMParser image/svg+xml + importNode, text fallback) in the
intro-kinetic-cascade and logo-split-lockup-pulse frame templates, clearing the
DOM-XSS (CWE-79) Snyk Fail. Renders identical SVG.
- pr-to-video: fetch-people-avatars.mjs refuses any avatar URL that is not https
on a GitHub avatar host (SSRF guard) and only writes under the project dir
(path-traversal guard); best-effort, always-exit-0 behaviour is unchanged.
- embedded-captions: pin `uvx --from whisperx==3.8.6` (overridable via
$WHISPERX_VERSION) so transcription no longer resolves "latest" at runtime.
- gsap: add Subresource Integrity (integrity + crossorigin) to the 8 render-time
CDN GSAP <script> tags across embedded-captions, music-to-video,
faceless-explainer, pr-to-video and product-launch-video.
- hyperframes-animation / hyperframes-creative: document package-loader's
defense-in-depth and note that the installLine strings are display-only.
Verified: media-use resolve (12/12), probe injection (1/1) and manifest (19/19)
tests pass; avatar host-allowlist checks pass; all changed JS passes node --check
and oxfmt.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(skills): clarify product-launch-video vs website-to-video routing
Sharpen the router's product-vs-site decision in hyperframes/SKILL.md: the
split is now "is the site selling a product?" — yes (SaaS / app / product /
company site) → /product-launch-video (a promo; the default for any commercial
URL, even if the site is only named); no, or the user just wants the site shown
as-is (portfolio / blog / docs / personal / event) → /website-to-video (a tour).
Updates the workflow table, the disambiguation bullet, and both workflows'
Input/Output blurbs to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(skills): satisfy oxfmt in the two music-to-video templates
The CI Format job runs `oxfmt --check .`, which also formats embedded <script> in .html. Reflow the setSvg() blocks added for the DOM-XSS fix to oxfmt's wrapping — no logic change. Regenerate the music-to-video manifest hash to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(skills): sanitize SVG in music-to-video templates (real CWE-79 fix)
Addresses @Magi's review: the previous setSvg() only swapped the sink
(innerHTML → DOMParser + importNode) but did NOT sanitize, so active SVG
content still executed on insertion into the live document. Verified in
headless Chrome that the old shape fired both an svg `onload` handler and an
inline `<script>`.
setSvg() now runs a default-deny cleanSvg() over the parsed tree before it ever
enters the document: only an allow-list of inert drawing elements
(svg/g/path/line/rect/circle/… ) and presentation attributes
(d/fill/stroke/viewBox/…) survives. Every other element (`<script>`, `<image>`,
`<use>`, `<foreignObject>`, `<a>`, `<animate>`, …), every `on*` handler, and
href/xlink:href/style are stripped — on the root node too. Non-SVG or malformed
input still falls back to textContent.
Trusted content (the bundled icon library + the default spark/cloud marks)
renders byte-identically; only hostile markup in vars.icon / leftMark / rightMark
is neutralized.
Browser-verified (headless Chrome, both templates' helper):
old setSvg → fired ["script","onload"]
new setSvg → fired [] · trusted icon still renders · 0 danger nodes · 0 on* attrs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8694424807
commit
535297280a
@@ -236,6 +236,104 @@
|
||||
"</svg>"
|
||||
);
|
||||
}
|
||||
// Insert SVG markup as SANITIZED parsed nodes — never innerHTML, and never
|
||||
// a raw appendChild. DOMParser builds the tree, then cleanSvg() hard-strips
|
||||
// everything that isn't inert drawing: only allow-listed shape elements +
|
||||
// presentation attributes survive, so <script>, <image>/<use>/<foreignObject>,
|
||||
// javascript: hrefs and every on* handler are removed before the nodes ever
|
||||
// enter the live document. A custom icon SVG therefore can't smuggle active
|
||||
// content (CWE-79 DOM-XSS). Non-SVG / malformed input falls back to text.
|
||||
var SVG_OK_TAGS = {
|
||||
svg: 1,
|
||||
g: 1,
|
||||
path: 1,
|
||||
line: 1,
|
||||
polyline: 1,
|
||||
polygon: 1,
|
||||
rect: 1,
|
||||
circle: 1,
|
||||
ellipse: 1,
|
||||
defs: 1,
|
||||
lineargradient: 1,
|
||||
radialgradient: 1,
|
||||
stop: 1,
|
||||
clippath: 1,
|
||||
title: 1,
|
||||
desc: 1,
|
||||
text: 1,
|
||||
tspan: 1,
|
||||
};
|
||||
var SVG_OK_ATTRS = {
|
||||
viewbox: 1,
|
||||
xmlns: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
fill: 1,
|
||||
"fill-rule": 1,
|
||||
"fill-opacity": 1,
|
||||
stroke: 1,
|
||||
"stroke-width": 1,
|
||||
"stroke-linecap": 1,
|
||||
"stroke-linejoin": 1,
|
||||
"stroke-dasharray": 1,
|
||||
"stroke-dashoffset": 1,
|
||||
"stroke-opacity": 1,
|
||||
opacity: 1,
|
||||
d: 1,
|
||||
x: 1,
|
||||
y: 1,
|
||||
x1: 1,
|
||||
y1: 1,
|
||||
x2: 1,
|
||||
y2: 1,
|
||||
cx: 1,
|
||||
cy: 1,
|
||||
r: 1,
|
||||
rx: 1,
|
||||
ry: 1,
|
||||
points: 1,
|
||||
transform: 1,
|
||||
offset: 1,
|
||||
"stop-color": 1,
|
||||
"stop-opacity": 1,
|
||||
gradientunits: 1,
|
||||
gradienttransform: 1,
|
||||
"clip-path": 1,
|
||||
"clip-rule": 1,
|
||||
class: 1,
|
||||
id: 1,
|
||||
};
|
||||
function cleanSvg(node) {
|
||||
var attrs = Array.prototype.slice.call(node.attributes || []);
|
||||
for (var a = 0; a < attrs.length; a++) {
|
||||
if (!SVG_OK_ATTRS[attrs[a].name.toLowerCase()]) node.removeAttribute(attrs[a].name);
|
||||
}
|
||||
var kids = Array.prototype.slice.call(node.childNodes);
|
||||
for (var k = 0; k < kids.length; k++) {
|
||||
var c = kids[k];
|
||||
if (c.nodeType === 1) {
|
||||
if (SVG_OK_TAGS[(c.localName || c.nodeName).toLowerCase()]) cleanSvg(c);
|
||||
else node.removeChild(c);
|
||||
} else if (c.nodeType !== 3) {
|
||||
node.removeChild(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
function setSvg(el, markup) {
|
||||
el.textContent = "";
|
||||
var doc = new DOMParser().parseFromString(String(markup), "image/svg+xml");
|
||||
var root = doc.documentElement;
|
||||
if (
|
||||
!root ||
|
||||
root.nodeName.toLowerCase() !== "svg" ||
|
||||
doc.getElementsByTagName("parsererror").length
|
||||
) {
|
||||
el.textContent = String(markup);
|
||||
return;
|
||||
}
|
||||
cleanSvg(root);
|
||||
el.appendChild(document.importNode(root, true));
|
||||
}
|
||||
|
||||
// ── 4. Default program (reversed from act0-intro-bell) ──
|
||||
var DEFAULT_PHRASES = [
|
||||
@@ -381,9 +479,9 @@
|
||||
glyph.style.height = iconPx + "px";
|
||||
glyph.style.color = TH.ink;
|
||||
if (ICONS[iconSpec]) {
|
||||
glyph.innerHTML = iconSVG(iconSpec); // library icon
|
||||
setSvg(glyph, iconSVG(iconSpec)); // library icon
|
||||
} else if (iconSpec.slice(0, 4).toLowerCase() === "<svg") {
|
||||
glyph.innerHTML = iconSpec; // custom inline SVG
|
||||
setSvg(glyph, iconSpec); // custom inline SVG
|
||||
} else {
|
||||
glyph.style.fontSize = Math.round(iconPx * 0.86) + "px"; // emoji / text glyph
|
||||
glyph.textContent = iconSpec;
|
||||
|
||||
@@ -361,10 +361,109 @@
|
||||
document.documentElement.style.setProperty("--mark", vars.markColor);
|
||||
document.documentElement.style.setProperty("--text", vars.textColor);
|
||||
|
||||
// Insert SVG markup as SANITIZED parsed nodes — never innerHTML, and never
|
||||
// a raw appendChild. DOMParser builds the tree, then cleanSvg() hard-strips
|
||||
// everything that isn't inert drawing: only allow-listed shape elements +
|
||||
// presentation attributes survive, so <script>, <image>/<use>/<foreignObject>,
|
||||
// javascript: hrefs and every on* handler are removed before the nodes ever
|
||||
// enter the live document. A custom mark SVG therefore can't smuggle active
|
||||
// content (CWE-79 DOM-XSS). Non-SVG / malformed input falls back to text.
|
||||
var SVG_OK_TAGS = {
|
||||
svg: 1,
|
||||
g: 1,
|
||||
path: 1,
|
||||
line: 1,
|
||||
polyline: 1,
|
||||
polygon: 1,
|
||||
rect: 1,
|
||||
circle: 1,
|
||||
ellipse: 1,
|
||||
defs: 1,
|
||||
lineargradient: 1,
|
||||
radialgradient: 1,
|
||||
stop: 1,
|
||||
clippath: 1,
|
||||
title: 1,
|
||||
desc: 1,
|
||||
text: 1,
|
||||
tspan: 1,
|
||||
};
|
||||
var SVG_OK_ATTRS = {
|
||||
viewbox: 1,
|
||||
xmlns: 1,
|
||||
width: 1,
|
||||
height: 1,
|
||||
fill: 1,
|
||||
"fill-rule": 1,
|
||||
"fill-opacity": 1,
|
||||
stroke: 1,
|
||||
"stroke-width": 1,
|
||||
"stroke-linecap": 1,
|
||||
"stroke-linejoin": 1,
|
||||
"stroke-dasharray": 1,
|
||||
"stroke-dashoffset": 1,
|
||||
"stroke-opacity": 1,
|
||||
opacity: 1,
|
||||
d: 1,
|
||||
x: 1,
|
||||
y: 1,
|
||||
x1: 1,
|
||||
y1: 1,
|
||||
x2: 1,
|
||||
y2: 1,
|
||||
cx: 1,
|
||||
cy: 1,
|
||||
r: 1,
|
||||
rx: 1,
|
||||
ry: 1,
|
||||
points: 1,
|
||||
transform: 1,
|
||||
offset: 1,
|
||||
"stop-color": 1,
|
||||
"stop-opacity": 1,
|
||||
gradientunits: 1,
|
||||
gradienttransform: 1,
|
||||
"clip-path": 1,
|
||||
"clip-rule": 1,
|
||||
class: 1,
|
||||
id: 1,
|
||||
};
|
||||
function cleanSvg(node) {
|
||||
var attrs = Array.prototype.slice.call(node.attributes || []);
|
||||
for (var a = 0; a < attrs.length; a++) {
|
||||
if (!SVG_OK_ATTRS[attrs[a].name.toLowerCase()]) node.removeAttribute(attrs[a].name);
|
||||
}
|
||||
var kids = Array.prototype.slice.call(node.childNodes);
|
||||
for (var k = 0; k < kids.length; k++) {
|
||||
var c = kids[k];
|
||||
if (c.nodeType === 1) {
|
||||
if (SVG_OK_TAGS[(c.localName || c.nodeName).toLowerCase()]) cleanSvg(c);
|
||||
else node.removeChild(c);
|
||||
} else if (c.nodeType !== 3) {
|
||||
node.removeChild(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
function setSvg(el, markup) {
|
||||
el.textContent = "";
|
||||
var doc = new DOMParser().parseFromString(String(markup), "image/svg+xml");
|
||||
var root = doc.documentElement;
|
||||
if (
|
||||
!root ||
|
||||
root.nodeName.toLowerCase() !== "svg" ||
|
||||
doc.getElementsByTagName("parsererror").length
|
||||
) {
|
||||
el.textContent = String(markup);
|
||||
return;
|
||||
}
|
||||
cleanSvg(root);
|
||||
el.appendChild(document.importNode(root, true));
|
||||
}
|
||||
|
||||
var markLeft = document.getElementById("markLeft");
|
||||
var markRight = document.getElementById("markRight");
|
||||
markLeft.innerHTML = (vars.leftMark && vars.leftMark.trim()) || DEFAULT_LEFT;
|
||||
markRight.innerHTML = (vars.rightMark && vars.rightMark.trim()) || DEFAULT_RIGHT;
|
||||
setSvg(markLeft, (vars.leftMark && vars.leftMark.trim()) || DEFAULT_LEFT);
|
||||
setSvg(markRight, (vars.rightMark && vars.rightMark.trim()) || DEFAULT_RIGHT);
|
||||
|
||||
var wordsEl = document.getElementById("words");
|
||||
var wordList = [vars.word1, vars.word2, vars.word3, vars.word4].filter(function (w) {
|
||||
|
||||
Reference in New Issue
Block a user