Coverage for gws-app/gws/lib/svg/element.py: 95%
42 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-16 23:09 +0200
1# sanitizer
3from typing import Optional
5import re
6from typing import Optional, Dict, Pattern
8import gws
9import gws.lib.xmlx as xmlx
10import gws.lib.mime
11import gws.lib.image
13_SVG_TAG_ATTS = {
14 'xmlns': 'http://www.w3.org/2000/svg',
15}
18def fragment_to_element(fragment: list[gws.XmlElement], atts: dict = None) -> gws.XmlElement:
19 """Convert an SVG fragment to an SVG element."""
21 fr = sorted(fragment, key=lambda el: el.attrib.get('z-index', 0))
22 return xmlx.tag('svg', _SVG_TAG_ATTS, atts, *fr)
25def fragment_to_image(fragment: list[gws.XmlElement], size: gws.Size, mime=gws.lib.mime.PNG) -> gws.lib.image.Image:
26 """Convert an SVG fragment to a raster image."""
28 el = fragment_to_element(fragment)
29 return gws.lib.image.from_svg(el.to_string(), size, mime)
32def sanitize_element(el: gws.XmlElement) -> Optional[gws.XmlElement]:
33 """Remove unsafe stuff from an SVG element."""
35 children = gws.u.compact(_sanitize(c) for c in el)
36 if children:
37 return xmlx.tag('svg', _sanitize_atts(el.attrib), *children)
40##
42_ALLOWED_TAGS = {
43 'circle',
44 'clippath',
45 'defs',
46 'ellipse',
47 'g',
48 'hatch',
49 'hatchpath',
50 'line',
51 'lineargradient',
52 'marker',
53 'mask',
54 'mesh',
55 'meshgradient',
56 'meshpatch',
57 'meshrow',
58 'mpath',
59 'path',
60 'pattern',
61 'polygon',
62 'polyline',
63 'radialgradient',
64 'rect',
65 'solidcolor',
66 'symbol',
67 'text',
68 'textpath',
69 'title',
70 'tspan',
71 'use',
72}
74# Regex patterns for attribute validation
75_RE_COLOR = r'^(#[0-9A-Fa-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d%,.\s]+\)|aliceblue|antiquewhite|aqua|aquamarine|azure|beige|bisque|black|blanchedalmond|blue|blueviolet|brown|burlywood|cadetblue|chartreuse|chocolate|coral|cornflowerblue|cornsilk|crimson|cyan|darkblue|darkcyan|darkgoldenrod|darkgray|darkgreen|darkgrey|darkkhaki|darkmagenta|darkolivegreen|darkorange|darkorchid|darkred|darksalmon|darkseagreen|darkslateblue|darkslategray|darkslategrey|darkturquoise|darkviolet|deeppink|deepskyblue|dimgray|dimgrey|dodgerblue|firebrick|floralwhite|forestgreen|fuchsia|gainsboro|ghostwhite|gold|goldenrod|gray|green|greenyellow|grey|honeydew|hotpink|indianred|indigo|ivory|khaki|lavender|lavenderblush|lawngreen|lemonchiffon|lightblue|lightcoral|lightcyan|lightgoldenrodyellow|lightgray|lightgreen|lightgrey|lightpink|lightsalmon|lightseagreen|lightskyblue|lightslategray|lightslategrey|lightsteelblue|lightyellow|lime|limegreen|linen|magenta|maroon|mediumaquamarine|mediumblue|mediumorchid|mediumpurple|mediumseagreen|mediumslateblue|mediumspringgreen|mediumturquoise|mediumvioletred|midnightblue|mintcream|mistyrose|moccasin|navajowhite|navy|oldlace|olive|olivedrab|orange|orangered|orchid|palegoldenrod|palegreen|paleturquoise|palevioletred|papayawhip|peachpuff|peru|pink|plum|powderblue|purple|red|rosybrown|royalblue|saddlebrown|salmon|sandybrown|seagreen|seashell|sienna|silver|skyblue|slateblue|slategray|slategrey|snow|springgreen|steelblue|tan|teal|thistle|tomato|turquoise|violet|wheat|white|whitesmoke|yellow|yellowgreen|transparent|currentColor)$'
76_RE_NUMBER = r'^-?\d+(\.\d+)?(px|em|ex|pt|pc|cm|mm|in|%)?$'
77_RE_OPACITY = r'^(0(\.\d+)?|1(\.0+)?)$'
78_RE_PATH = r'^[mMlLhHvVcCsSqQtTaAzZ0-9\s,.-]+$'
79_RE_TRANSFORM = r'^(matrix|translate|scale|rotate|skewX|skewY)\([\d\s,.-]+\)( (matrix|translate|scale|rotate|skewX|skewY)\([\d\s,.-]+\))*$'
80_RE_VIEWBOX = r'^\d+(\.\d+)?\s+\d+(\.\d+)?\s+\d+(\.\d+)?\s+\d+(\.\d+)?$'
81_RE_TEXT = r'^[^<>]*$'
82_RE_FONT_FAMILY = r'^[^<>"\']*$'
83_RE_ANY = r'.*'
85# Dictionary of allowed attributes with their validation patterns
86_ALLOWED_ATTRIBUTES: Dict[str, Pattern] = {
87 'alignment-baseline': re.compile(_RE_TEXT),
88 'baseline-shift': re.compile(_RE_TEXT),
89 'clip': re.compile(_RE_TEXT),
90 'clip-path': re.compile(r'^url\(#[a-zA-Z0-9_-]+\)$'),
91 'clip-rule': re.compile(r'^(nonzero|evenodd)$'),
92 'color': re.compile(_RE_COLOR),
93 'color-interpolation': re.compile(r'^(auto|sRGB|linearRGB)$'),
94 'color-interpolation-filters': re.compile(r'^(auto|sRGB|linearRGB)$'),
95 'color-profile': re.compile(_RE_TEXT),
96 'color-rendering': re.compile(r'^(auto|optimizeSpeed|optimizeQuality)$'),
97 'cursor': re.compile(_RE_TEXT),
98 'd': re.compile(_RE_PATH),
99 'direction': re.compile(r'^(ltr|rtl)$'),
100 'display': re.compile(r'^(inline|block|list-item|run-in|compact|marker|table|inline-table|table-row-group|table-header-group|table-footer-group|table-row|table-column-group|table-column|table-cell|table-caption|none)$'),
101 'dominant-baseline': re.compile(_RE_TEXT),
102 'enable-background': re.compile(_RE_TEXT),
103 'fill': re.compile(_RE_COLOR),
104 'fill-opacity': re.compile(_RE_OPACITY),
105 'fill-rule': re.compile(r'^(nonzero|evenodd)$'),
106 'filter': re.compile(r'^url\(#[a-zA-Z0-9_-]+\)$'),
107 'flood-color': re.compile(_RE_COLOR),
108 'flood-opacity': re.compile(_RE_OPACITY),
109 'font-family': re.compile(_RE_FONT_FAMILY),
110 'font-size': re.compile(_RE_NUMBER),
111 'font-size-adjust': re.compile(_RE_NUMBER),
112 'font-stretch': re.compile(r'^(normal|wider|narrower|ultra-condensed|extra-condensed|condensed|semi-condensed|semi-expanded|expanded|extra-expanded|ultra-expanded)$'),
113 'font-style': re.compile(r'^(normal|italic|oblique)$'),
114 'font-variant': re.compile(r'^(normal|small-caps)$'),
115 'font-weight': re.compile(r'^(normal|bold|bolder|lighter|100|200|300|400|500|600|700|800|900)$'),
116 'glyph-orientation-horizontal': re.compile(_RE_NUMBER),
117 'glyph-orientation-vertical': re.compile(_RE_NUMBER),
118 'image-rendering': re.compile(r'^(auto|optimizeSpeed|optimizeQuality)$'),
119 'kerning': re.compile(_RE_TEXT),
120 'letter-spacing': re.compile(_RE_NUMBER),
121 'lighting-color': re.compile(_RE_COLOR),
122 'marker-end': re.compile(r'^url\(#[a-zA-Z0-9_-]+\)$'),
123 'marker-mid': re.compile(r'^url\(#[a-zA-Z0-9_-]+\)$'),
124 'marker-start': re.compile(r'^url\(#[a-zA-Z0-9_-]+\)$'),
125 'mask': re.compile(r'^url\(#[a-zA-Z0-9_-]+\)$'),
126 'opacity': re.compile(_RE_OPACITY),
127 'overflow': re.compile(r'^(visible|hidden|scroll|auto)$'),
128 'pointer-events': re.compile(r'^(visiblePainted|visibleFill|visibleStroke|visible|painted|fill|stroke|all|none)$'),
129 'shape-rendering': re.compile(r'^(auto|optimizeSpeed|crispEdges|geometricPrecision)$'),
130 'stop-color': re.compile(_RE_COLOR),
131 'stop-opacity': re.compile(_RE_OPACITY),
132 'stroke': re.compile(_RE_COLOR),
133 'stroke-dasharray': re.compile(r'^(none|[\d\s,.]*)$'),
134 'stroke-dashoffset': re.compile(_RE_NUMBER),
135 'stroke-linecap': re.compile(r'^(butt|round|square)$'),
136 'stroke-linejoin': re.compile(r'^(miter|round|bevel)$'),
137 'stroke-miterlimit': re.compile(_RE_NUMBER),
138 'stroke-opacity': re.compile(_RE_OPACITY),
139 'stroke-width': re.compile(_RE_NUMBER),
140 'text-anchor': re.compile(r'^(start|middle|end)$'),
141 'text-decoration': re.compile(r'^(none|underline|overline|line-through|blink)$'),
142 'text-rendering': re.compile(r'^(auto|optimizeSpeed|optimizeLegibility|geometricPrecision)$'),
143 'transform': re.compile(_RE_TRANSFORM),
144 'transform-origin': re.compile(_RE_TEXT),
145 'unicode-bidi': re.compile(_RE_TEXT),
146 'vector-effect': re.compile(r'^(none|non-scaling-stroke)$'),
147 'visibility': re.compile(r'^(visible|hidden|collapse)$'),
148 'word-spacing': re.compile(_RE_NUMBER),
149 'writing-mode': re.compile(r'^(lr-tb|rl-tb|tb-rl|lr|rl|tb)$'),
150 'width': re.compile(_RE_NUMBER),
151 'height': re.compile(_RE_NUMBER),
152 'viewBox': re.compile(_RE_VIEWBOX),
153}
156def _sanitize(el: gws.XmlElement) -> Optional[gws.XmlElement]:
157 if el.name in _ALLOWED_TAGS:
158 return xmlx.tag(
159 el.name,
160 _sanitize_atts(el.attrib),
161 gws.u.compact(_sanitize(c) for c in el.children()))
164def _sanitize_atts(atts: dict) -> dict:
165 res = {}
166 for k, v in atts.items():
167 # Skip if attribute is not in allowed list
168 if k not in _ALLOWED_ATTRIBUTES:
169 continue
171 # Skip URLs that could lead to XSS
172 if v.strip().startswith(('http:', 'https:', 'data:')):
173 continue
175 # Validate attribute value against its regex pattern
176 if _ALLOWED_ATTRIBUTES[k].match(v.strip()):
177 res[k] = v
179 return res