Coverage for paperless/validators.py: 14%

63 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 09:07 +0000

1from io import BytesIO 

2 

3from django.conf import settings 

4from django.core.exceptions import ValidationError 

5from django.core.files.uploadedfile import UploadedFile 

6from lxml import etree 

7from PIL import Image 

8 

9ALLOWED_SVG_TAGS: set[str] = { 

10 # Basic shapes 

11 "svg", # Root SVG element 

12 "g", # Group elements together 

13 "path", # Draw complex shapes with commands 

14 "rect", # Rectangle 

15 "circle", # Circle 

16 "ellipse", # Ellipse/oval 

17 "line", # Straight line 

18 "polyline", # Connected lines (open path) 

19 "polygon", # Connected lines (closed path) 

20 # Text 

21 "text", # Text container 

22 "tspan", # Text span within text 

23 "textpath", # Text along a path 

24 "style", # Embedded CSS 

25 # Definitions and reusable content 

26 "defs", # Container for reusable elements 

27 "symbol", # Reusable graphic template 

28 "use", # Reference/instantiate reusable elements 

29 "marker", # Arrowheads and path markers 

30 "pattern", # Repeating pattern fills 

31 "mask", # Masking effects 

32 # Gradients 

33 "lineargradient", # Linear gradient fill 

34 "radialgradient", # Radial gradient fill 

35 "stop", # Gradient color stop 

36 # Clipping 

37 "clippath", # Clipping path definition 

38 # Metadata 

39 "title", # Accessible title 

40 "desc", # Accessible description 

41 "metadata", # Document metadata 

42} 

43 

44ALLOWED_SVG_ATTRIBUTES: set[str] = { 

45 # Core attributes 

46 "id", # Unique identifier 

47 "class", # CSS class names 

48 "style", # Inline CSS styles (validate content separately!) 

49 # Positioning and sizing 

50 "x", # X coordinate 

51 "y", # Y coordinate 

52 "cx", # Center X coordinate (circle/ellipse) 

53 "cy", # Center Y coordinate (circle/ellipse) 

54 "r", # Radius (circle) 

55 "rx", # X radius (ellipse, rounded corners) 

56 "ry", # Y radius (ellipse, rounded corners) 

57 "width", # Width 

58 "height", # Height 

59 "x1", # Start X (line, gradient) 

60 "y1", # Start Y (line, gradient) 

61 "x2", # End X (line, gradient) 

62 "y2", # End Y (line, gradient) 

63 "dx", # X offset (text) 

64 "dy", # Y offset (text) 

65 "points", # Point list for polyline/polygon 

66 # Path data 

67 "d", # Path commands and coordinates 

68 # Fill properties 

69 "fill", # Fill color or none 

70 "fill-opacity", # Fill transparency 

71 "fill-rule", # Fill algorithm (nonzero/evenodd) 

72 "color", # Current color 

73 # Stroke properties 

74 "stroke", # Stroke color or none 

75 "stroke-width", # Stroke thickness 

76 "stroke-opacity", # Stroke transparency 

77 "stroke-linecap", # Line ending style (butt/round/square) 

78 "stroke-linejoin", # Corner style (miter/round/bevel) 

79 "stroke-miterlimit", # Miter join limit 

80 "stroke-dasharray", # Dash pattern 

81 "stroke-dashoffset", # Dash pattern offset 

82 "vector-effect", # Non-scaling stroke, etc. 

83 "clip-rule", # Rule for clipping paths 

84 # Transforms and positioning 

85 "overflow", # Overflow behavior 

86 "transform", # Transformations (translate/rotate/scale) 

87 "viewbox", # Coordinate system and viewport 

88 "preserveaspectratio", # Scaling behavior 

89 # Opacity 

90 "opacity", # Overall element opacity 

91 # Gradient attributes 

92 "gradienttransform", # Transform applied to gradient 

93 "gradientunits", # Gradient coordinate system 

94 "spreadmethod", # Gradient spread method 

95 "fx", # Radial gradient focal point X 

96 "fy", # Radial gradient focal point Y 

97 "fr", # Radial gradient focal radius 

98 "offset", # Position of gradient stop 

99 "stop-color", # Color at gradient stop 

100 "stop-opacity", # Opacity at gradient stop 

101 # Clipping and masking 

102 "clip-path", # Reference to clipping path 

103 "mask", # Reference to mask 

104 # Markers 

105 "marker-start", # Marker at path start 

106 "marker-mid", # Marker at path vertices 

107 "marker-end", # Marker at path end 

108 "markerunits", # Marker coordinate system 

109 "markerwidth", # Marker viewport width 

110 "markerheight", # Marker viewport height 

111 "refx", # Marker reference point X 

112 "refy", # Marker reference point Y 

113 "orient", # Marker orientation 

114 # Text attributes 

115 "font-family", # Font name 

116 "font-size", # Font size 

117 "font-weight", # Font weight (normal/bold) 

118 "font-style", # Font style (normal/italic) 

119 "text-anchor", # Text alignment (start/middle/end) 

120 "text-decoration", # Text decoration (underline/etc) 

121 "letter-spacing", # Space between letters 

122 "word-spacing", # Space between words 

123 "text-rendering", # Text rendering hint 

124 "shape-rendering", # Shape rendering hint 

125 "image-rendering", # Image rendering hint 

126 "startoffset", # TextPath start offset 

127 "method", # TextPath method 

128 "spacing", # TextPath spacing 

129 # Links and references 

130 "href", # Link or reference (validate for javascript:!) 

131 "xlink:href", # Legacy link reference (validate for javascript:!) 

132 "xlink:title", # Accessible title for links 

133 # Pattern attributes 

134 "patternunits", # Pattern coordinate system 

135 "patterntransform", # Transform applied to pattern 

136 "patterncontentunits", # Pattern content coordinate system 

137 # Mask attributes 

138 "maskunits", # Mask coordinate system 

139 "maskcontentunits", # Mask content coordinate system 

140 # SVG namespace declarations 

141 "xmlns", # XML namespace (usually http://www.w3.org/2000/svg) 

142 "xmlns:xlink", # XLink namespace 

143 "version", # SVG version 

144 "type", 

145 # Accessibility 

146 "aria-label", 

147 "aria-hidden", 

148 "role", 

149 "focusable", 

150} 

151 

152# Dangerous patterns in style attributes that can execute code 

153DANGEROUS_STYLE_PATTERNS: set[str] = { 

154 "javascript:", # javascript: URLs in url() functions 

155 "data:text/html", # HTML data URIs can contain scripts 

156 "expression(", # IE's CSS expressions (legacy but dangerous) 

157 "import", # CSS @import can load external resources 

158 "@import", # CSS @import directive 

159 "-moz-binding:", # Firefox XBL bindings (can execute code) 

160 "behaviour:", # IE behavior property 

161 "behavior:", # IE behavior property (US spelling) 

162 "vbscript:", # VBScript URLs 

163 "data:application/", # Data URIs for arbitrary application payloads 

164} 

165 

166XLINK_NS: set[str] = { 

167 "http://www.w3.org/1999/xlink", 

168 "https://www.w3.org/1999/xlink", 

169} 

170 

171# Dangerous URI schemes 

172DANGEROUS_SCHEMES: set[str] = { 

173 "javascript:", 

174 "data:text/html", 

175 "vbscript:", 

176 "file:", 

177 "data:application/", # Can contain scripts 

178} 

179 

180SAFE_PREFIXES: set[str] = {"#", "/", "./", "../", "data:image/"} 

181 

182 

183def reject_dangerous_svg(file: UploadedFile) -> None: 

184 """ 

185 Rejects SVG files that contain dangerous tags or attributes. 

186 Raises ValidationError if unsafe content is found. 

187 See GHSA-6p53-hqqw-8j62 

188 """ 

189 

190 try: 

191 parser = etree.XMLParser(resolve_entities=False) 

192 file.seek(0) 

193 tree = etree.parse(file, parser) 

194 root = tree.getroot() 

195 except etree.XMLSyntaxError: 

196 raise ValidationError("Invalid SVG file.") 

197 

198 for element in root.iter(): 

199 tag: str = etree.QName(element.tag).localname.lower() 

200 if tag not in ALLOWED_SVG_TAGS: 

201 raise ValidationError(f"Disallowed SVG tag: <{tag}>") 

202 

203 if tag == "style": 

204 # Combine all text (including CDATA) to scan for dangerous patterns 

205 style_text: str = "".join(element.itertext()).lower() 

206 for pattern in DANGEROUS_STYLE_PATTERNS: 

207 if pattern in style_text: 

208 raise ValidationError( 

209 f"Disallowed pattern in <style> content: {pattern}", 

210 ) 

211 

212 attr_name: str 

213 attr_value: str 

214 for attr_name, attr_value in element.attrib.items(): 

215 # lxml expands namespaces to {url}name. We must convert the standard 

216 # XLink namespace back to 'xlink:' so it matches our allowlist. 

217 if attr_name.startswith("{"): 

218 qname = etree.QName(attr_name) 

219 if qname.namespace in XLINK_NS: 

220 attr_name_check = f"xlink:{qname.localname}" 

221 else: 

222 # Unknown namespace: keep raw name (will fail allowlist) 

223 attr_name_check = attr_name 

224 else: 

225 attr_name_check = attr_name 

226 

227 attr_name_lower = attr_name_check.lower().strip() 

228 

229 if attr_name_lower not in ALLOWED_SVG_ATTRIBUTES: 

230 raise ValidationError(f"Disallowed SVG attribute: {attr_name}") 

231 

232 if attr_name_lower == "style": 

233 style_lower: str = attr_value.lower() 

234 # Check if any dangerous pattern is a substring of the style 

235 for pattern in DANGEROUS_STYLE_PATTERNS: 

236 if pattern in style_lower: 

237 raise ValidationError( 

238 f"Disallowed pattern in style attribute: {pattern}", 

239 ) 

240 

241 # Validate URI attributes (href, xlink:href) 

242 if attr_name_lower in {"href", "xlink:href"}: 

243 value_stripped: str = attr_value.strip().lower() 

244 

245 # Check if value starts with any dangerous scheme 

246 for scheme in DANGEROUS_SCHEMES: 

247 if value_stripped.startswith(scheme): 

248 raise ValidationError( 

249 f"Disallowed URI scheme in {attr_name}: {scheme}", 

250 ) 

251 

252 # Allow safe schemes for logos: #anchor, relative paths, data:image/* 

253 # No external resources (http/https) needed for logos 

254 

255 if value_stripped and not any( 

256 value_stripped.startswith(prefix) for prefix in SAFE_PREFIXES 

257 ): 

258 raise ValidationError( 

259 f"URI scheme not allowed in {attr_name}: must be #anchor, relative path, or data:image/*", 

260 ) 

261 

262 

263def validate_raster_image(file: UploadedFile) -> None: 

264 """ 

265 Validates that the uploaded file is a valid raster image (JPEG, PNG, etc.) 

266 and does not exceed maximum pixel limits. 

267 Raises ValidationError if the image is invalid or exceeds the allowed size. 

268 """ 

269 

270 file.seek(0) 

271 image_data = file.read() 

272 try: 

273 with Image.open(BytesIO(image_data)) as image: 

274 image.verify() 

275 

276 if ( 

277 settings.MAX_IMAGE_PIXELS is not None 

278 and settings.MAX_IMAGE_PIXELS > 0 

279 and image.width * image.height > settings.MAX_IMAGE_PIXELS 

280 ): 

281 raise ValidationError( 

282 "Uploaded logo exceeds the maximum allowed image size.", 

283 ) 

284 if image.format is None: # pragma: no cover 

285 raise ValidationError("Invalid logo image.") 

286 except (OSError, Image.DecompressionBombError) as e: 

287 raise ValidationError("Invalid logo image.") from e