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

1# sanitizer 

2 

3from typing import Optional 

4 

5import re 

6from typing import Optional, Dict, Pattern 

7 

8import gws 

9import gws.lib.xmlx as xmlx 

10import gws.lib.mime 

11import gws.lib.image 

12 

13_SVG_TAG_ATTS = { 

14 'xmlns': 'http://www.w3.org/2000/svg', 

15} 

16 

17 

18def fragment_to_element(fragment: list[gws.XmlElement], atts: dict = None) -> gws.XmlElement: 

19 """Convert an SVG fragment to an SVG element.""" 

20 

21 fr = sorted(fragment, key=lambda el: el.attrib.get('z-index', 0)) 

22 return xmlx.tag('svg', _SVG_TAG_ATTS, atts, *fr) 

23 

24 

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.""" 

27 

28 el = fragment_to_element(fragment) 

29 return gws.lib.image.from_svg(el.to_string(), size, mime) 

30 

31 

32def sanitize_element(el: gws.XmlElement) -> Optional[gws.XmlElement]: 

33 """Remove unsafe stuff from an SVG element.""" 

34 

35 children = gws.u.compact(_sanitize(c) for c in el) 

36 if children: 

37 return xmlx.tag('svg', _sanitize_atts(el.attrib), *children) 

38 

39 

40## 

41 

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} 

73 

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'.*' 

84 

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} 

154 

155 

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())) 

162 

163 

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 

170 

171 # Skip URLs that could lead to XSS 

172 if v.strip().startswith(('http:', 'https:', 'data:')): 

173 continue 

174 

175 # Validate attribute value against its regex pattern 

176 if _ALLOWED_ATTRIBUTES[k].match(v.strip()): 

177 res[k] = v 

178 

179 return res