Coverage for paperless/validators.py: 14%
63 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
1from io import BytesIO
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
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}
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}
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}
166XLINK_NS: set[str] = {
167 "http://www.w3.org/1999/xlink",
168 "https://www.w3.org/1999/xlink",
169}
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}
180SAFE_PREFIXES: set[str] = {"#", "/", "./", "../", "data:image/"}
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 """
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.")
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}>")
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 )
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
227 attr_name_lower = attr_name_check.lower().strip()
229 if attr_name_lower not in ALLOWED_SVG_ATTRIBUTES:
230 raise ValidationError(f"Disallowed SVG attribute: {attr_name}")
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 )
241 # Validate URI attributes (href, xlink:href)
242 if attr_name_lower in {"href", "xlink:href"}:
243 value_stripped: str = attr_value.strip().lower()
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 )
252 # Allow safe schemes for logos: #anchor, relative paths, data:image/*
253 # No external resources (http/https) needed for logos
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 )
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 """
270 file.seek(0)
271 image_data = file.read()
272 try:
273 with Image.open(BytesIO(image_data)) as image:
274 image.verify()
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