diff --git a/docs/dist/v1/chartifact.markdown.umd.js b/docs/dist/v1/chartifact.markdown.umd.js index 844cf301..2d68fbc2 100644 --- a/docs/dist/v1/chartifact.markdown.umd.js +++ b/docs/dist/v1/chartifact.markdown.umd.js @@ -1216,7 +1216,7 @@ ${reconstitutedRules.join("\n\n")} } function reconstituteCss(atRules) { const cssBlocks = []; - for (const atRule of Object.values(atRules)) { + for (const atRule of Object.keys(atRules).map((key) => atRules[key])) { const reconstructed = reconstituteAtRule(atRule); if (reconstructed.trim()) { cssBlocks.push(reconstructed); @@ -1254,7 +1254,38 @@ ${reconstitutedRules.join("\n\n")} "starting-style", "position-try" ]; - function checkSecurityIssues(node) { + const findAtRuleContext = (targetNode, rootNode) => { + let context = ""; + csstree.walk(rootNode, (node, item, list) => { + if (node.type === "Atrule" && node.block) { + const atRuleSignature = `@${node.name}${node.prelude ? ` ${csstree.generate(node.prelude)}` : ""}`; + let foundTarget = false; + csstree.walk(node.block, (innerNode) => { + if (innerNode === targetNode) { + foundTarget = true; + } + }); + if (foundTarget) { + context = atRuleSignature; + } + } + }); + return context; + }; + const addRuleToContext = (rule, context) => { + if (!spec.atRules[context]) { + spec.atRules[context] = { + signature: context, + rules: [] + }; + } + if (spec.atRules[context].rules) { + spec.atRules[context].rules.push(rule); + } else { + spec.atRules[context].rules = [rule]; + } + }; + const checkSecurityIssues = (node) => { if (node.type === "Function" && node.name === "expression") { return { flag: "scriptExec", reason: "CSS expression() function detected" }; } @@ -1267,8 +1298,8 @@ ${reconstitutedRules.join("\n\n")} return { flag: "scriptExec", reason: `${urlStr.split(":")[0]} URL detected` }; } if (urlStr.startsWith("data:")) { - if (urlStr.includes("data:image/svg+xml")) { - if (urlStr.includes(" 0) { - const targetAtRule = currentAtRuleSignature; - if (!spec.atRules[targetAtRule]) { - spec.atRules[targetAtRule] = { - signature: targetAtRule, - rules: [] - }; - } - if (spec.atRules[targetAtRule].rules) { - spec.atRules[targetAtRule].rules.push(currentRule); - } else { - spec.atRules[targetAtRule].rules = [currentRule]; - } - } - }; const ast = csstree.parse(cssContent); - let currentRule = null; - let currentAtRuleSignature = ""; + const pendingRules = []; csstree.walk(ast, (node) => { if (node.type === "Atrule") { const atRuleSignature = `@${node.name}${node.prelude ? ` ${csstree.generate(node.prelude)}` : ""}`; @@ -1328,7 +1342,7 @@ ${reconstitutedRules.join("\n\n")} result.reasons.push(reason); return; } - if (completeBlockAtRules.includes(node.name)) { + if (completeBlockAtRules.indexOf(node.name) !== -1) { const ruleContent = csstree.generate(node); spec.atRules[atRuleSignature] = { signature: atRuleSignature, @@ -1343,7 +1357,6 @@ ${reconstitutedRules.join("\n\n")} rules: [] }; } - currentAtRuleSignature = atRuleSignature; } else { const ruleContent = csstree.generate(node); spec.atRules[atRuleSignature] = { @@ -1352,41 +1365,50 @@ ${reconstitutedRules.join("\n\n")} }; } } else if (node.type === "Rule") { - addCurrentRule(); + const context = findAtRuleContext(node, ast); const selector = csstree.generate(node.prelude); - currentRule = { + const rule = { selector, declarations: [] }; - } else if (node.type === "Declaration" && currentRule) { - const declCss = csstree.generate(node); - const declaration = { css: declCss }; - const securityCheck = checkSecurityIssues(node); - if (securityCheck) { - declaration.css = `/* omitted (${securityCheck.reason}) */`; - declaration.unsafeCss = declCss; - declaration.flag = securityCheck.flag; - declaration.reason = securityCheck.reason; - result.hasFlags = true; - result.reasons.push(securityCheck.reason); - } - currentRule.declarations.push(declaration); - } else if (currentRule && (node.type === "Function" || node.type === "Url" || node.type === "String" || node.type === "Identifier")) { - const securityCheck = checkSecurityIssues(node); - if (securityCheck && currentRule.declarations.length > 0) { - const lastDecl = currentRule.declarations[currentRule.declarations.length - 1]; - if (!lastDecl.flag) { - lastDecl.unsafeCss = lastDecl.css; - lastDecl.css = `/* omitted (${securityCheck.reason}) */`; - lastDecl.flag = securityCheck.flag; - lastDecl.reason = securityCheck.reason; + pendingRules.push({ rule, context, node }); + } + }); + for (const { rule, context, node } of pendingRules) { + csstree.walk(node, (declNode) => { + if (declNode.type === "Declaration") { + const declCss = csstree.generate(declNode); + const declaration = { css: declCss }; + const securityCheck = checkSecurityIssues(declNode); + if (securityCheck) { + declaration.css = `/* omitted (${securityCheck.reason}) */`; + declaration.unsafeCss = declCss; + declaration.flag = securityCheck.flag; + declaration.reason = securityCheck.reason; result.hasFlags = true; result.reasons.push(securityCheck.reason); + } else { + csstree.walk(declNode, (childNode) => { + if (childNode !== declNode && (childNode.type === "Function" || childNode.type === "Url" || childNode.type === "String" || childNode.type === "Identifier")) { + const childSecurityCheck = checkSecurityIssues(childNode); + if (childSecurityCheck && !declaration.flag) { + declaration.unsafeCss = declaration.css; + declaration.css = `/* omitted (${childSecurityCheck.reason}) */`; + declaration.flag = childSecurityCheck.flag; + declaration.reason = childSecurityCheck.reason; + result.hasFlags = true; + result.reasons.push(childSecurityCheck.reason); + } + } + }); } + rule.declarations.push(declaration); } + }); + if (rule.declarations.length > 0) { + addRuleToContext(rule, context); } - }); - addCurrentRule(); + } } catch (parseError) { throw new Error(`CSS parsing failed: ${parseError.message}`); } diff --git a/packages/markdown/src/plugins/css.ts b/packages/markdown/src/plugins/css.ts index 5db66f21..a14963f9 100644 --- a/packages/markdown/src/plugins/css.ts +++ b/packages/markdown/src/plugins/css.ts @@ -70,7 +70,7 @@ function reconstituteAtRule(atRule: AtRule): string { function reconstituteCss(atRules: { [atRuleSignature: string]: AtRule }): string { const cssBlocks: string[] = []; - for (const atRule of Object.values(atRules)) { + for (const atRule of Object.keys(atRules).map(key => atRules[key])) { const reconstructed = reconstituteAtRule(atRule); if (reconstructed.trim()) { cssBlocks.push(reconstructed); @@ -114,8 +114,50 @@ function categorizeCss(cssContent: string) { 'position-try' ]; + // Helper function to find the at-rule context for a given node + const findAtRuleContext = (targetNode: any, rootNode: any): string => { + let context = ''; // Default to global context + + // Walk through the AST to find the parent at-rule that contains our target node + csstree.walk(rootNode, (node, item, list) => { + if (node.type === 'Atrule' && node.block) { + const atRuleSignature = `@${node.name}${node.prelude ? ` ${csstree.generate(node.prelude)}` : ''}`; + + // Check if our target node is inside this at-rule's block + let foundTarget = false; + csstree.walk(node.block, (innerNode) => { + if (innerNode === targetNode) { + foundTarget = true; + } + }); + + if (foundTarget) { + context = atRuleSignature; + } + } + }); + + return context; + }; + + // Helper function to add a rule to the correct at-rule context + const addRuleToContext = (rule: Rule, context: string) => { + if (!spec.atRules[context]) { + spec.atRules[context] = { + signature: context, + rules: [] + }; + } + + if (spec.atRules[context].rules) { + spec.atRules[context].rules.push(rule); + } else { + spec.atRules[context].rules = [rule]; + } + }; + // Helper function to check for security issues using AST node analysis - function checkSecurityIssues(node: any): Pick | null { + const checkSecurityIssues = (node: any): Pick | null => { // Check for script execution in CSS expressions if (node.type === 'Function' && node.name === 'expression') { return { flag: 'scriptExec', reason: 'CSS expression() function detected' }; @@ -137,8 +179,8 @@ function categorizeCss(cssContent: string) { // Data URLs need specific checking if (urlStr.startsWith('data:')) { // SVG data URLs can contain scripts - if (urlStr.includes('data:image/svg+xml')) { - if (urlStr.includes(' 0) { - const targetAtRule = currentAtRuleSignature; - - if (!spec.atRules[targetAtRule]) { - spec.atRules[targetAtRule] = { - signature: targetAtRule, - rules: [] - }; - } - - if (spec.atRules[targetAtRule].rules) { - spec.atRules[targetAtRule].rules.push(currentRule); - } else { - spec.atRules[targetAtRule].rules = [currentRule]; - } - } - } - - // Single walk through the AST - process each node once + // First pass: collect all at-rules and rules csstree.walk(ast, (node) => { if (node.type === 'Atrule') { const atRuleSignature = `@${node.name}${node.prelude ? ` ${csstree.generate(node.prelude)}` : ''}`; @@ -221,7 +242,7 @@ function categorizeCss(cssContent: string) { } // For at-rules that should be treated as complete blocks - if (completeBlockAtRules.includes(node.name)) { + if (completeBlockAtRules.indexOf(node.name) !== -1) { // Store the entire rule as CSS and validate it as a complete block const ruleContent = csstree.generate(node); spec.atRules[atRuleSignature] = { @@ -233,14 +254,13 @@ function categorizeCss(cssContent: string) { // For other at-rules that contain rules (like @media, @supports) if (node.block) { + // Initialize the at-rule container if it doesn't exist if (!spec.atRules[atRuleSignature]) { spec.atRules[atRuleSignature] = { signature: atRuleSignature, rules: [] }; } - // Set current context for nested rules - currentAtRuleSignature = atRuleSignature; } else { // Simple at-rule without block const ruleContent = csstree.generate(node); @@ -251,55 +271,66 @@ function categorizeCss(cssContent: string) { } } else if (node.type === 'Rule') { - // Finish previous rule if one exists - addCurrentRule(); - - // Start building a new rule + // Determine the correct context for this rule + const context = findAtRuleContext(node, ast); + + // Start building the rule const selector = csstree.generate(node.prelude); - currentRule = { + const rule: Rule = { selector, declarations: [] }; + + // Store the rule with its context for later processing + pendingRules.push({ rule, context, node }); + } + }); - } else if (node.type === 'Declaration' && currentRule) { - // Process declaration within the current rule - const declCss = csstree.generate(node); - const declaration: Declaration = { css: declCss }; - - // Check this declaration node directly for security issues - const securityCheck = checkSecurityIssues(node); - if (securityCheck) { - declaration.css = `/* omitted (${securityCheck.reason}) */`; - declaration.unsafeCss = declCss; - declaration.flag = securityCheck.flag; - declaration.reason = securityCheck.reason; - result.hasFlags = true; - result.reasons.push(securityCheck.reason); - } - - currentRule.declarations.push(declaration); - - } else if (currentRule && - (node.type === 'Function' || node.type === 'Url' || - node.type === 'String' || node.type === 'Identifier')) { - // Check child nodes of the current declaration for security issues - const securityCheck = checkSecurityIssues(node); - if (securityCheck && currentRule.declarations.length > 0) { - const lastDecl = currentRule.declarations[currentRule.declarations.length - 1]; - if (!lastDecl.flag) { // Only flag if not already flagged - lastDecl.unsafeCss = lastDecl.css; // Preserve original before overwriting - lastDecl.css = `/* omitted (${securityCheck.reason}) */`; - lastDecl.flag = securityCheck.flag; - lastDecl.reason = securityCheck.reason; + // Second pass: process declarations for each rule + for (const { rule, context, node } of pendingRules) { + csstree.walk(node, (declNode) => { + if (declNode.type === 'Declaration') { + // Process declaration within the current rule + const declCss = csstree.generate(declNode); + const declaration: Declaration = { css: declCss }; + + // Check this declaration node directly for security issues + const securityCheck = checkSecurityIssues(declNode); + if (securityCheck) { + declaration.css = `/* omitted (${securityCheck.reason}) */`; + declaration.unsafeCss = declCss; + declaration.flag = securityCheck.flag; + declaration.reason = securityCheck.reason; result.hasFlags = true; result.reasons.push(securityCheck.reason); + } else { + // Check child nodes of the declaration for security issues + csstree.walk(declNode, (childNode) => { + if (childNode !== declNode && + (childNode.type === 'Function' || childNode.type === 'Url' || + childNode.type === 'String' || childNode.type === 'Identifier')) { + const childSecurityCheck = checkSecurityIssues(childNode); + if (childSecurityCheck && !declaration.flag) { + declaration.unsafeCss = declaration.css; + declaration.css = `/* omitted (${childSecurityCheck.reason}) */`; + declaration.flag = childSecurityCheck.flag; + declaration.reason = childSecurityCheck.reason; + result.hasFlags = true; + result.reasons.push(childSecurityCheck.reason); + } + } + }); } + + rule.declarations.push(declaration); } + }); + + // Add the completed rule to the correct context + if (rule.declarations.length > 0) { + addRuleToContext(rule, context); } - }); - - // Don't forget to add the last rule if it exists - addCurrentRule(); + } } catch (parseError) { // Don't swallow CSS parsing errors - throw them so they can be handled upstream