max / makeover
1 file changed,
+25 insertions,
-3 deletions
| @@ -258,10 +258,14 @@ impl SemanticTokens { | |||
| 258 | 258 | pub fn resolve(theme: &ThemeColors) -> SemanticTokens { | |
| 259 | 259 | let mut intents: BTreeMap<String, String> = BTreeMap::new(); | |
| 260 | 260 | ||
| 261 | - | // 1. Base intents (authored). | |
| 261 | + | // 1. Base intents (authored). Copy only values that parse as a hex color and | |
| 262 | + | // re-emit them in canonical `#rrggbb` form, so an authored value can never | |
| 263 | + | // carry arbitrary bytes into the emitted CSS (the resolved tokens are inlined | |
| 264 | + | // raw into a `<style>` block by the web server). A malformed value is skipped, | |
| 265 | + | // mirroring the skip-missing behavior for absent intents. | |
| 262 | 266 | for (src, token) in BASE_INTENTS { | |
| 263 | - | if let Some(v) = theme.colors.get(*src) { | |
| 264 | - | intents.insert((*token).to_string(), v.clone()); | |
| 267 | + | if let Some(rgb) = theme.colors.get(*src).and_then(|v| Rgb::from_hex(v)) { | |
| 268 | + | intents.insert((*token).to_string(), rgb.to_hex()); | |
| 265 | 269 | } | |
| 266 | 270 | } | |
| 267 | 271 | ||
| @@ -811,6 +815,24 @@ six = "#88c0d0" | |||
| 811 | 815 | } | |
| 812 | 816 | ||
| 813 | 817 | #[test] | |
| 818 | + | fn resolve_drops_non_hex_base_intent() { | |
| 819 | + | // A base intent that isn't a hex color must never reach the resolved | |
| 820 | + | // token set (it would otherwise be inlined verbatim into a <style> | |
| 821 | + | // block). Skipped like a missing intent; valid siblings survive. | |
| 822 | + | let theme = parse_theme_str( | |
| 823 | + | "x", | |
| 824 | + | "[surface]\npage = \"</style><script>alert(1)</script>\"\n[content]\nprimary = \"#111111\"\n", | |
| 825 | + | false, | |
| 826 | + | ) | |
| 827 | + | .unwrap(); | |
| 828 | + | let t = resolve(&theme); | |
| 829 | + | assert!(t.hex("surface-page").is_none(), "non-hex base intent leaked"); | |
| 830 | + | assert_eq!(t.hex("content").unwrap(), "#111111"); | |
| 831 | + | // The injected markup appears in no resolved value. | |
| 832 | + | assert!(!t.intents.values().any(|v| v.contains('<'))); | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | #[test] | |
| 814 | 836 | fn resolve_skips_derived_when_source_missing() { | |
| 815 | 837 | // No [action] => no action-derived tokens. | |
| 816 | 838 | let theme = parse_theme_str( |