svg-sanitize 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexandre Dulaunoy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: svg-sanitize
3
+ Version: 0.1.0
4
+ Summary: Strict, passive-only SVG reconstruction/sanitization prototype
5
+ Author-email: Alexandre Dulaunoy <a@foo.be>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: defusedxml<1,>=0.7.1
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=7; extra == "test"
13
+ Dynamic: license-file
14
+
15
+ # svg-sanitize — restrictive, visual-only SVG sanitizer (prototype)
16
+
17
+ `svg-sanitize` rebuilds untrusted SVG as **static vector graphics only**. It does
18
+ not simply search and replace `script` tags. It parses XML with `defusedxml`,
19
+ checks resource limits, and constructs an entirely new XML tree from a small,
20
+ explicit allowlist of elements, attributes, and valid values.
21
+
22
+ **Prototype, not a formally verified security boundary.** Review, fuzz and test
23
+ against your target SVG renderers before using this in a security-sensitive
24
+ production system.
25
+
26
+ ## Why svg-sanitize?
27
+
28
+ The **svg-sanitize** project started following security reviews conducted by **Jeroen Pinoy**, which highlighted the potential security risks associated with handling untrusted SVG files. This initial prototype was done during the hack.lu 2026 hackathon after the hot ones interview ;-)
29
+
30
+ Although SVG is primarily an image format, it can contain active content, including JavaScript, event handlers, external resource references, and other potentially malicious elements. This makes SVG a potential attack vector, particularly when displaying user-supplied images in web applications.
31
+
32
+ **svg-sanitize** is a lightweight Python library designed to transform untrusted SVG files into passive, visual-only SVG documents. Instead of attempting to identify every possible malicious payload, the library follows a strict allowlist approach, preserving only elements and attributes required for static vector graphics.
33
+
34
+ The objective is to provide a simple, reusable, and open-source security component that reduces the attack surface when processing SVG files, without requiring applications to implement their own SVG sanitization logic.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ python -m pip install -e './svg-sanitize[test]'
40
+ # or, when inside the extracted directory:
41
+ python -m pip install -e '.[test]'
42
+ ```
43
+
44
+ Python 3.10+; the only runtime dependency is `defusedxml`.
45
+
46
+ ## Python usage
47
+
48
+ ```python
49
+ from svg_sanitize import sanitize_svg, SVGValidationError
50
+
51
+ untrusted_svg = b'''<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">
52
+ <script>alert(1)</script>
53
+ <rect width="80" height="40" fill="red"/>
54
+ </svg>'''
55
+
56
+ try:
57
+ passive_svg = sanitize_svg(untrusted_svg)
58
+ except SVGValidationError as exc:
59
+ print(f"SVG rejected: {exc}")
60
+ else:
61
+ print(passive_svg)
62
+ # <svg xmlns="http://www.w3.org/2000/svg"><rect width="80" height="40" fill="red" /></svg>
63
+ ```
64
+
65
+ For custom input and rendering limits:
66
+
67
+ ```python
68
+ from svg_sanitize import Limits, sanitize_svg
69
+
70
+ output = sanitize_svg(untrusted_svg, limits=Limits(max_input_bytes=100_000, max_nodes=1_000))
71
+ ```
72
+
73
+ ## CLI
74
+
75
+ ```bash
76
+ svg-sanitize examples/malicious.svg -o clean.svg
77
+ # alternatively:
78
+ python -m svg_sanitize examples/malicious.svg -o clean.svg
79
+ ```
80
+
81
+ Run tests:
82
+
83
+ ```bash
84
+ python -m pytest -q
85
+ ```
86
+
87
+ ## CI and PyPI releases
88
+
89
+ The [GitHub Actions workflow](.github/workflows/ci.yml) runs the tests on Python
90
+ 3.10–3.14 for pull requests, pushes to `main`, and published GitHub releases.
91
+ After the tests pass, it builds and checks both the source distribution and wheel.
92
+ Only a published release triggers an upload to PyPI, using those checked artifacts.
93
+
94
+ Before the first release:
95
+
96
+ 1. Create a GitHub Actions environment named `pypi` in this repository. Add any
97
+ desired deployment protection rules there.
98
+ 2. Configure a [PyPI trusted publisher](https://docs.pypi.org/trusted-publishers/)
99
+ for the `svg-sanitize` project with owner `adulau`, repository `svg-sanitize`,
100
+ workflow filename `ci.yml`, and environment name `pypi`. If the project does not
101
+ exist yet, configure a pending publisher in your PyPI account instead.
102
+
103
+ No PyPI API token or GitHub repository secret is required. For each release,
104
+ update `project.version` in `pyproject.toml`, merge the change, then create and
105
+ publish a GitHub release from that commit with a matching tag, such as `v0.1.0`
106
+ or `0.1.0`. A mismatched tag fails before publishing. Each upload must use a new
107
+ version because PyPI does not allow replacing previously uploaded files.
108
+ Published prereleases also run this workflow; drafts do not.
109
+
110
+ ## What is allowed
111
+
112
+ Only `svg`, `g`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`,
113
+ `path`, `text`, and `tspan`. Numeric dimensions and coordinates, path data,
114
+ point lists, limited transforms, limited named/hex/rgb colors, static stroke/
115
+ fill and selected text formatting properties are validated individually.
116
+
117
+ SVGs using gradients, filters, clipping paths, `<use>`, images, embedded fonts,
118
+ CSS, masks, symbols, hyperlinks, animation or scripting **will lose those
119
+ features**. In particular, gradient fills `fill="url(#id)"` are discarded,
120
+ including local URLs. This is an explicit security/functionality tradeoff.
121
+
122
+ ## What is forbidden
123
+
124
+ - JavaScript and scriptable elements; `onload`, `onclick`, and all event handlers.
125
+ - CSS (`<style>`, `style=`), including `@import`, `url(...)`, and expressions.
126
+ - All links and resources, including `href`, `xlink:href`, external and internal
127
+ references, `data:` URLs, `<image>`, `<use>`, fonts, filters and gradients.
128
+ - `<foreignObject>` and arbitrary/HTML/XML namespace elements.
129
+ - SVG animation and SMIL (`animate`, `set`, `animateTransform`, etc.).
130
+ - DTDs, XML entity declarations, external entity references, malformed XML.
131
+ - Unknown attributes (including `id`, `class`, `xml:base`, and `xmlns:xlink`).
132
+
133
+ Unknown elements are removed **with their complete subtrees**; the input is
134
+ never copied wholesale to output. The implementation also enforces limits on
135
+ input bytes, output bytes, node count, depth, attribute sizes and text sizes.
136
+
137
+ ## Deployment advice
138
+
139
+ For untrusted-user uploads, make the sanitized document only one defense layer:
140
+
141
+ 1. Keep the original SVG out of any publicly served directory.
142
+ 2. Serve the sanitized output with `Content-Type: image/svg+xml`, ideally from
143
+ a separate cookieless domain with `Content-Disposition: attachment` for
144
+ downloads. For display, prefer `<img src="...">` rather than inline SVG.
145
+ 3. Use CSP restricting `script-src 'none'; object-src 'none'; base-uri 'none';
146
+ default-src 'none'; img-src 'self' data:` on the serving endpoint where
147
+ appropriate, and ensure application CSP is also restrictive.
148
+ 4. Treat the output as untrusted in any subsequent XML/SVG manipulation pipeline;
149
+ downstream software might add back risky features. If preserving more complex
150
+ SVG features is necessary, consider rasterizing with a hardened, resource-
151
+ isolated renderer instead of broadening the allowlist.
152
+ 5. Use browser-based regression tests, fuzz tests, and multiple-engine testing
153
+ before relying on the package for production security.
154
+
155
+ **No SVG sanitizer can promise protection against all future rendering-engine
156
+ vulnerabilities.** This prototype targets active-content injection and external
157
+ resource-loading pathways, not malicious graphical impersonation or renderer
158
+ memory-corruption bugs.
159
+
160
+ MIT licensed (see `LICENSE`).
@@ -0,0 +1,146 @@
1
+ # svg-sanitize — restrictive, visual-only SVG sanitizer (prototype)
2
+
3
+ `svg-sanitize` rebuilds untrusted SVG as **static vector graphics only**. It does
4
+ not simply search and replace `script` tags. It parses XML with `defusedxml`,
5
+ checks resource limits, and constructs an entirely new XML tree from a small,
6
+ explicit allowlist of elements, attributes, and valid values.
7
+
8
+ **Prototype, not a formally verified security boundary.** Review, fuzz and test
9
+ against your target SVG renderers before using this in a security-sensitive
10
+ production system.
11
+
12
+ ## Why svg-sanitize?
13
+
14
+ The **svg-sanitize** project started following security reviews conducted by **Jeroen Pinoy**, which highlighted the potential security risks associated with handling untrusted SVG files. This initial prototype was done during the hack.lu 2026 hackathon after the hot ones interview ;-)
15
+
16
+ Although SVG is primarily an image format, it can contain active content, including JavaScript, event handlers, external resource references, and other potentially malicious elements. This makes SVG a potential attack vector, particularly when displaying user-supplied images in web applications.
17
+
18
+ **svg-sanitize** is a lightweight Python library designed to transform untrusted SVG files into passive, visual-only SVG documents. Instead of attempting to identify every possible malicious payload, the library follows a strict allowlist approach, preserving only elements and attributes required for static vector graphics.
19
+
20
+ The objective is to provide a simple, reusable, and open-source security component that reduces the attack surface when processing SVG files, without requiring applications to implement their own SVG sanitization logic.
21
+
22
+ ## Installation
23
+
24
+ ```bash
25
+ python -m pip install -e './svg-sanitize[test]'
26
+ # or, when inside the extracted directory:
27
+ python -m pip install -e '.[test]'
28
+ ```
29
+
30
+ Python 3.10+; the only runtime dependency is `defusedxml`.
31
+
32
+ ## Python usage
33
+
34
+ ```python
35
+ from svg_sanitize import sanitize_svg, SVGValidationError
36
+
37
+ untrusted_svg = b'''<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">
38
+ <script>alert(1)</script>
39
+ <rect width="80" height="40" fill="red"/>
40
+ </svg>'''
41
+
42
+ try:
43
+ passive_svg = sanitize_svg(untrusted_svg)
44
+ except SVGValidationError as exc:
45
+ print(f"SVG rejected: {exc}")
46
+ else:
47
+ print(passive_svg)
48
+ # <svg xmlns="http://www.w3.org/2000/svg"><rect width="80" height="40" fill="red" /></svg>
49
+ ```
50
+
51
+ For custom input and rendering limits:
52
+
53
+ ```python
54
+ from svg_sanitize import Limits, sanitize_svg
55
+
56
+ output = sanitize_svg(untrusted_svg, limits=Limits(max_input_bytes=100_000, max_nodes=1_000))
57
+ ```
58
+
59
+ ## CLI
60
+
61
+ ```bash
62
+ svg-sanitize examples/malicious.svg -o clean.svg
63
+ # alternatively:
64
+ python -m svg_sanitize examples/malicious.svg -o clean.svg
65
+ ```
66
+
67
+ Run tests:
68
+
69
+ ```bash
70
+ python -m pytest -q
71
+ ```
72
+
73
+ ## CI and PyPI releases
74
+
75
+ The [GitHub Actions workflow](.github/workflows/ci.yml) runs the tests on Python
76
+ 3.10–3.14 for pull requests, pushes to `main`, and published GitHub releases.
77
+ After the tests pass, it builds and checks both the source distribution and wheel.
78
+ Only a published release triggers an upload to PyPI, using those checked artifacts.
79
+
80
+ Before the first release:
81
+
82
+ 1. Create a GitHub Actions environment named `pypi` in this repository. Add any
83
+ desired deployment protection rules there.
84
+ 2. Configure a [PyPI trusted publisher](https://docs.pypi.org/trusted-publishers/)
85
+ for the `svg-sanitize` project with owner `adulau`, repository `svg-sanitize`,
86
+ workflow filename `ci.yml`, and environment name `pypi`. If the project does not
87
+ exist yet, configure a pending publisher in your PyPI account instead.
88
+
89
+ No PyPI API token or GitHub repository secret is required. For each release,
90
+ update `project.version` in `pyproject.toml`, merge the change, then create and
91
+ publish a GitHub release from that commit with a matching tag, such as `v0.1.0`
92
+ or `0.1.0`. A mismatched tag fails before publishing. Each upload must use a new
93
+ version because PyPI does not allow replacing previously uploaded files.
94
+ Published prereleases also run this workflow; drafts do not.
95
+
96
+ ## What is allowed
97
+
98
+ Only `svg`, `g`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`,
99
+ `path`, `text`, and `tspan`. Numeric dimensions and coordinates, path data,
100
+ point lists, limited transforms, limited named/hex/rgb colors, static stroke/
101
+ fill and selected text formatting properties are validated individually.
102
+
103
+ SVGs using gradients, filters, clipping paths, `<use>`, images, embedded fonts,
104
+ CSS, masks, symbols, hyperlinks, animation or scripting **will lose those
105
+ features**. In particular, gradient fills `fill="url(#id)"` are discarded,
106
+ including local URLs. This is an explicit security/functionality tradeoff.
107
+
108
+ ## What is forbidden
109
+
110
+ - JavaScript and scriptable elements; `onload`, `onclick`, and all event handlers.
111
+ - CSS (`<style>`, `style=`), including `@import`, `url(...)`, and expressions.
112
+ - All links and resources, including `href`, `xlink:href`, external and internal
113
+ references, `data:` URLs, `<image>`, `<use>`, fonts, filters and gradients.
114
+ - `<foreignObject>` and arbitrary/HTML/XML namespace elements.
115
+ - SVG animation and SMIL (`animate`, `set`, `animateTransform`, etc.).
116
+ - DTDs, XML entity declarations, external entity references, malformed XML.
117
+ - Unknown attributes (including `id`, `class`, `xml:base`, and `xmlns:xlink`).
118
+
119
+ Unknown elements are removed **with their complete subtrees**; the input is
120
+ never copied wholesale to output. The implementation also enforces limits on
121
+ input bytes, output bytes, node count, depth, attribute sizes and text sizes.
122
+
123
+ ## Deployment advice
124
+
125
+ For untrusted-user uploads, make the sanitized document only one defense layer:
126
+
127
+ 1. Keep the original SVG out of any publicly served directory.
128
+ 2. Serve the sanitized output with `Content-Type: image/svg+xml`, ideally from
129
+ a separate cookieless domain with `Content-Disposition: attachment` for
130
+ downloads. For display, prefer `<img src="...">` rather than inline SVG.
131
+ 3. Use CSP restricting `script-src 'none'; object-src 'none'; base-uri 'none';
132
+ default-src 'none'; img-src 'self' data:` on the serving endpoint where
133
+ appropriate, and ensure application CSP is also restrictive.
134
+ 4. Treat the output as untrusted in any subsequent XML/SVG manipulation pipeline;
135
+ downstream software might add back risky features. If preserving more complex
136
+ SVG features is necessary, consider rasterizing with a hardened, resource-
137
+ isolated renderer instead of broadening the allowlist.
138
+ 5. Use browser-based regression tests, fuzz tests, and multiple-engine testing
139
+ before relying on the package for production security.
140
+
141
+ **No SVG sanitizer can promise protection against all future rendering-engine
142
+ vulnerabilities.** This prototype targets active-content injection and external
143
+ resource-loading pathways, not malicious graphical impersonation or renderer
144
+ memory-corruption bugs.
145
+
146
+ MIT licensed (see `LICENSE`).
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "svg-sanitize"
7
+ authors = [{name = "Alexandre Dulaunoy", email = "a@foo.be"}]
8
+ version = "0.1.0"
9
+ description = "Strict, passive-only SVG reconstruction/sanitization prototype"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ license = {text = "MIT"}
13
+ dependencies = ["defusedxml>=0.7.1,<1"]
14
+
15
+ [project.optional-dependencies]
16
+ test = ["pytest>=7"]
17
+
18
+ [project.scripts]
19
+ svg-sanitize = "svg_sanitize.__main__:main"
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Build purely visual SVG output from untrusted SVG/XML."""
2
+
3
+ from .sanitizer import Limits, SVGValidationError, sanitize_svg
4
+
5
+ __all__ = ["Limits", "SVGValidationError", "sanitize_svg"]
@@ -0,0 +1,31 @@
1
+ """CLI: python -m svg_sanitize untrusted.svg -o clean.svg"""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import Limits, SVGValidationError, sanitize_svg
8
+
9
+
10
+ def main() -> int:
11
+ parser = argparse.ArgumentParser(description="Strip SVG down to static visual primitives")
12
+ parser.add_argument("input", type=Path, help="SVG input file")
13
+ parser.add_argument("-o", "--output", type=Path, help="SVG output file (default stdout)")
14
+ args = parser.parse_args()
15
+ limits = Limits()
16
+ try:
17
+ with args.input.open("rb") as f:
18
+ content = f.read(limits.max_input_bytes + 1)
19
+ cleaned = sanitize_svg(content, limits=limits)
20
+ if args.output:
21
+ args.output.write_text(cleaned + "\n", encoding="utf-8")
22
+ else:
23
+ sys.stdout.write(cleaned + "\n")
24
+ except (SVGValidationError, OSError) as exc:
25
+ print(f"svg-sanitize: {exc}", file=sys.stderr)
26
+ return 1
27
+ return 0
28
+
29
+
30
+ if __name__ == "__main__":
31
+ raise SystemExit(main())
@@ -0,0 +1,392 @@
1
+ """Strict, visual-only SVG sanitizer.
2
+
3
+ This is intentionally not a general-purpose SVG optimizer. It constructs a
4
+ new XML tree from a small whitelist. Unsupported elements, attributes, and
5
+ attribute values do not reach the result.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ import re
12
+ import xml.etree.ElementTree as ET
13
+ from dataclasses import dataclass
14
+
15
+ from defusedxml import ElementTree as SafeET
16
+ from defusedxml.common import DefusedXmlException
17
+
18
+ SVG_NS = "http://www.w3.org/2000/svg"
19
+ ET.register_namespace("", SVG_NS)
20
+
21
+
22
+ class SVGValidationError(ValueError):
23
+ """The input SVG is invalid, prohibited XML, or exceeds resource limits."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Limits:
28
+ max_input_bytes: int = 1_048_576
29
+ max_output_bytes: int = 1_048_576
30
+ max_nodes: int = 10_000
31
+ max_depth: int = 64
32
+ max_attributes_per_node: int = 64
33
+ max_attribute_chars: int = 65_536
34
+ max_text_chars: int = 100_000
35
+ max_path_chars: int = 65_536
36
+
37
+
38
+ DEFAULT_LIMITS = Limits()
39
+
40
+ # Deliberately exclude: script, style, a, use, image, foreignObject, animate,
41
+ # set, filter, mask, clipPath, pattern, gradients, defs, metadata, etc.
42
+ CONTAINERS = frozenset({"svg", "g"})
43
+ SHAPES = frozenset({"rect", "circle", "ellipse", "line", "polyline", "polygon", "path"})
44
+ TEXT_TAGS = frozenset({"text", "tspan"})
45
+ ALLOWED_TAGS = CONTAINERS | SHAPES | TEXT_TAGS
46
+
47
+ PRESENTATION = frozenset({
48
+ "fill", "fill-opacity", "fill-rule", "stroke", "stroke-width",
49
+ "stroke-opacity", "stroke-linecap", "stroke-linejoin",
50
+ "stroke-miterlimit", "stroke-dasharray", "stroke-dashoffset",
51
+ "opacity", "display", "visibility", "vector-effect",
52
+ "font-family", "font-size", "font-style", "font-weight",
53
+ "text-anchor", "dominant-baseline", "letter-spacing", "word-spacing",
54
+ "shape-rendering", "text-rendering",
55
+ })
56
+
57
+ GEOMETRY = {
58
+ "svg": frozenset({"x", "y", "width", "height", "viewBox", "preserveAspectRatio", "transform"}),
59
+ "g": frozenset({"transform"}),
60
+ "rect": frozenset({"x", "y", "width", "height", "rx", "ry", "transform"}),
61
+ "circle": frozenset({"cx", "cy", "r", "transform"}),
62
+ "ellipse": frozenset({"cx", "cy", "rx", "ry", "transform"}),
63
+ "line": frozenset({"x1", "x2", "y1", "y2", "transform"}),
64
+ "polyline": frozenset({"points", "transform"}),
65
+ "polygon": frozenset({"points", "transform"}),
66
+ "path": frozenset({"d", "transform"}),
67
+ "text": frozenset({"x", "y", "dx", "dy", "transform"}),
68
+ "tspan": frozenset({"x", "y", "dx", "dy", "rotate"}),
69
+ }
70
+
71
+ NUMBER = r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
72
+ NUMBER_RE = re.compile(NUMBER)
73
+ LENGTH_RE = re.compile(rf"({NUMBER})(px|%)?\Z", re.ASCII)
74
+ COLOR_HEX_RE = re.compile(r"#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{1})?(?:[0-9a-fA-F]{2})?(?:[0-9a-fA-F]{2})?\Z")
75
+ COLOR_FUNC_RE = re.compile(r"(rgb|rgba)\(\s*([^()]*)\s*\)\Z", re.IGNORECASE)
76
+ PATH_TOKEN_RE = re.compile(rf"{NUMBER}|[MmLlHhVvCcSsQqTtAaZz]", re.ASCII)
77
+ TRANSFORM_RE = re.compile(
78
+ r"\s*(matrix|translate|scale|rotate|skewX|skewY)\s*\(\s*([^()]*)\s*\)",
79
+ re.IGNORECASE,
80
+ )
81
+ SAFE_SEPARATORS_RE = re.compile(r"[\s,]*\Z")
82
+ TEXT_CONTROL_RE = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]")
83
+
84
+ COLORS = frozenset({
85
+ "none", "transparent", "black", "white", "red", "green", "blue",
86
+ "yellow", "orange", "purple", "gray", "grey", "silver", "navy",
87
+ "teal", "lime", "aqua", "fuchsia", "maroon", "olive", "pink",
88
+ "brown", "cyan", "magenta", "gold", "indigo", "coral",
89
+ })
90
+ FONT_FAMILIES = {
91
+ "arial": "Arial", "helvetica": "Helvetica", "verdana": "Verdana",
92
+ "tahoma": "Tahoma", "georgia": "Georgia",
93
+ "times new roman": "Times New Roman", "courier new": "Courier New",
94
+ "serif": "serif", "sans-serif": "sans-serif", "monospace": "monospace",
95
+ "system-ui": "system-ui",
96
+ }
97
+ ATTRIBUTE_OPTIONS = {
98
+ "fill-rule": {"evenodd", "nonzero"},
99
+ "stroke-linecap": {"butt", "round", "square"},
100
+ "stroke-linejoin": {"miter", "round", "bevel"},
101
+ "display": {"inline", "none"},
102
+ "visibility": {"visible", "hidden", "collapse"},
103
+ "vector-effect": {"none", "non-scaling-stroke"},
104
+ "font-style": {"normal", "italic", "oblique"},
105
+ "text-anchor": {"start", "middle", "end"},
106
+ "dominant-baseline": {"auto", "alphabetic", "middle", "central", "hanging", "text-before-edge", "text-after-edge"},
107
+ "shape-rendering": {"auto", "optimizeSpeed", "crispEdges", "geometricPrecision"},
108
+ "text-rendering": {"auto", "optimizeSpeed", "optimizeLegibility", "geometricPrecision"},
109
+ }
110
+ COORD_ATTRS = frozenset({"x", "y", "x1", "x2", "y1", "y2", "cx", "cy", "dx", "dy"})
111
+ NONNEG_LENGTH_ATTRS = frozenset({"width", "height", "r", "rx", "ry", "stroke-width", "font-size"})
112
+ OPACITY_ATTRS = frozenset({"opacity", "fill-opacity", "stroke-opacity"})
113
+ OTHER_LENGTH_ATTRS = frozenset({"stroke-dashoffset", "letter-spacing", "word-spacing"})
114
+
115
+
116
+ def _number(value: str, *, minimum: float = -10_000_000, maximum: float = 10_000_000) -> bool:
117
+ if len(value) > 40 or not NUMBER_RE.fullmatch(value):
118
+ return False
119
+ number = float(value)
120
+ return math.isfinite(number) and minimum <= number <= maximum
121
+
122
+
123
+ def _length(value: str, *, minimum: float = -10_000_000) -> bool:
124
+ match = LENGTH_RE.fullmatch(value)
125
+ return bool(match and _number(match.group(1), minimum=minimum))
126
+
127
+
128
+ def _number_list(value: str, *, limit: int, minimum: float = -10_000_000) -> list[str] | None:
129
+ """Parse whitespace/comma-separated finite numbers, never arbitrary CSS."""
130
+ values: list[str] = []
131
+ previous_end = 0
132
+ for match in NUMBER_RE.finditer(value):
133
+ between = value[previous_end:match.start()]
134
+ if not SAFE_SEPARATORS_RE.fullmatch(between):
135
+ return None
136
+ # Adjacent numbers are allowed only if a sign starts the next one.
137
+ if previous_end == match.start() and values and not match.group().startswith(("+", "-")):
138
+ return None
139
+ if not _number(match.group(), minimum=minimum):
140
+ return None
141
+ values.append(match.group())
142
+ if len(values) > limit:
143
+ return None
144
+ previous_end = match.end()
145
+ if not SAFE_SEPARATORS_RE.fullmatch(value[previous_end:]):
146
+ return None
147
+ return values or None
148
+
149
+
150
+ def _color(value: str) -> bool:
151
+ if value.lower() in COLORS:
152
+ return True
153
+ if COLOR_HEX_RE.fullmatch(value) and len(value) in {4, 5, 7, 9}:
154
+ return True
155
+ match = COLOR_FUNC_RE.fullmatch(value)
156
+ if not match:
157
+ return False
158
+ mode, body = match.groups()
159
+ parts = [part.strip() for part in body.split(",")]
160
+ if len(parts) != (3 if mode.lower() == "rgb" else 4):
161
+ return False
162
+ for component in parts[:3]:
163
+ if component.endswith("%"):
164
+ if not _number(component[:-1], minimum=0, maximum=100):
165
+ return False
166
+ elif not _number(component, minimum=0, maximum=255):
167
+ return False
168
+ if len(parts) == 4 and not _number(parts[3], minimum=0, maximum=1):
169
+ return False
170
+ return True
171
+
172
+
173
+ def _path(value: str, max_chars: int) -> bool:
174
+ if not value or len(value) > max_chars:
175
+ return False
176
+ offset = 0
177
+ have_move = False
178
+ tokens = 0
179
+ for match in PATH_TOKEN_RE.finditer(value):
180
+ if not SAFE_SEPARATORS_RE.fullmatch(value[offset:match.start()]):
181
+ return False
182
+ token = match.group()
183
+ if token.isalpha():
184
+ if not have_move:
185
+ if token not in "Mm":
186
+ return False
187
+ have_move = True
188
+ elif not _number(token):
189
+ return False
190
+ tokens += 1
191
+ if tokens > 20_000:
192
+ return False
193
+ offset = match.end()
194
+ return have_move and SAFE_SEPARATORS_RE.fullmatch(value[offset:]) is not None
195
+
196
+
197
+ def _transform(value: str) -> bool:
198
+ if not value or len(value) > 512:
199
+ return False
200
+ counts = {"matrix": {6}, "translate": {1, 2}, "scale": {1, 2}, "rotate": {1, 3}, "skewx": {1}, "skewy": {1}}
201
+ offset = 0
202
+ functions = 0
203
+ while offset < len(value):
204
+ match = TRANSFORM_RE.match(value, offset)
205
+ if not match:
206
+ return False
207
+ parts = _number_list(match.group(2), limit=6)
208
+ if parts is None or len(parts) not in counts[match.group(1).lower()]:
209
+ return False
210
+ functions += 1
211
+ if functions > 16:
212
+ return False
213
+ offset = match.end()
214
+ if not value[offset:].strip():
215
+ return True
216
+ return functions > 0
217
+
218
+
219
+ def _viewbox(value: str) -> bool:
220
+ values = _number_list(value, limit=4)
221
+ return bool(values and len(values) == 4 and float(values[2]) > 0 and float(values[3]) > 0)
222
+
223
+
224
+ def _aspect_ratio(value: str) -> bool:
225
+ parts = value.split()
226
+ if parts and parts[0] == "defer":
227
+ parts = parts[1:]
228
+ return (len(parts) == 1 and parts[0] == "none") or (
229
+ len(parts) in (1, 2)
230
+ and parts[0] in {
231
+ "xMinYMin", "xMidYMin", "xMaxYMin",
232
+ "xMinYMid", "xMidYMid", "xMaxYMid",
233
+ "xMinYMax", "xMidYMax", "xMaxYMax",
234
+ }
235
+ and (len(parts) == 1 or parts[1] in {"meet", "slice"})
236
+ )
237
+
238
+
239
+ def _attribute(name: str, value: str, limits: Limits) -> str | None:
240
+ """Return a safe (possibly normalized) attribute value, or None."""
241
+ if not value or len(value) > limits.max_attribute_chars:
242
+ return None
243
+ value = value.strip()
244
+ if not value or TEXT_CONTROL_RE.search(value):
245
+ return None
246
+
247
+ if name in {"fill", "stroke"}:
248
+ return value if _color(value) else None
249
+ if name in OPACITY_ATTRS:
250
+ return value if _number(value, minimum=0, maximum=1) else None
251
+ if name in COORD_ATTRS | OTHER_LENGTH_ATTRS:
252
+ return value if _length(value) else None
253
+ if name in NONNEG_LENGTH_ATTRS:
254
+ return value if _length(value, minimum=0) else None
255
+ if name == "stroke-miterlimit":
256
+ return value if _number(value, minimum=1) else None
257
+ if name == "stroke-dasharray":
258
+ if value == "none":
259
+ return value
260
+ # Dasharray supports numbers and a very limited unit syntax here.
261
+ values = _number_list(value, limit=64, minimum=0)
262
+ return value if values else None
263
+ if name in ATTRIBUTE_OPTIONS:
264
+ return value if value in ATTRIBUTE_OPTIONS[name] else None
265
+ if name == "font-weight":
266
+ if value in {"normal", "bold", "bolder", "lighter"}:
267
+ return value
268
+ return value if value in {str(n) for n in range(100, 1000, 100)} else None
269
+ if name == "font-family":
270
+ parts = [part.strip().strip("\"'").lower() for part in value.split(",")]
271
+ if not (1 <= len(parts) <= 5) or any(part not in FONT_FAMILIES for part in parts):
272
+ return None
273
+ return ", ".join(FONT_FAMILIES[part] for part in parts)
274
+ if name == "viewBox":
275
+ return value if _viewbox(value) else None
276
+ if name == "preserveAspectRatio":
277
+ return value if _aspect_ratio(value) else None
278
+ if name == "points":
279
+ numbers = _number_list(value, limit=20000)
280
+ return value if numbers is not None and len(numbers) >= 2 and len(numbers) % 2 == 0 else None
281
+ if name == "d":
282
+ return value if _path(value, limits.max_path_chars) else None
283
+ if name == "transform":
284
+ return value if _transform(value) else None
285
+ if name == "rotate": # tspan rotation, one numeric angle only
286
+ return value if _number(value) else None
287
+ return None
288
+
289
+
290
+ def _tag_parts(tag: str) -> tuple[str | None, str]:
291
+ if tag.startswith("{"):
292
+ namespace, name = tag[1:].split("}", 1)
293
+ return namespace, name
294
+ return None, tag
295
+
296
+
297
+ def _clean_text(value: str | None) -> str | None:
298
+ return TEXT_CONTROL_RE.sub("", value) if value is not None else None
299
+
300
+
301
+ def _check_complexity(root: ET.Element, limits: Limits) -> None:
302
+ stack = [(root, 1)]
303
+ nodes = text_chars = 0
304
+ while stack:
305
+ node, depth = stack.pop()
306
+ nodes += 1
307
+ if nodes > limits.max_nodes or depth > limits.max_depth:
308
+ raise SVGValidationError("SVG exceeds element-count or nesting-depth limits")
309
+ if len(node.attrib) > limits.max_attributes_per_node:
310
+ raise SVGValidationError("SVG exceeds per-element attribute count limit")
311
+ if any(len(key) > 256 or len(value) > limits.max_attribute_chars for key, value in node.attrib.items()):
312
+ raise SVGValidationError("SVG has oversized attributes")
313
+ text_chars += len(node.text or "") + len(node.tail or "")
314
+ if text_chars > limits.max_text_chars:
315
+ raise SVGValidationError("SVG contains too much text")
316
+ stack.extend((child, depth + 1) for child in node)
317
+
318
+
319
+ def _can_contain(parent: str, child: str) -> bool:
320
+ if parent in CONTAINERS:
321
+ return child in (CONTAINERS | SHAPES | {"text"})
322
+ if parent in TEXT_TAGS:
323
+ return child == "tspan"
324
+ return False
325
+
326
+
327
+ def sanitize_svg(data: str | bytes, *, limits: Limits = DEFAULT_LIMITS) -> str:
328
+ """Return a rebuilt, self-contained, passive SVG document.
329
+
330
+ No CSS, URLs, links, event handlers, animation, scripts, resource references,
331
+ foreign elements, or XML entities survive. Unsupported subtrees are removed.
332
+ Malformed XML, DTDs, entities, wrong roots, and oversized inputs are rejected.
333
+
334
+ This is defense-in-depth, not a browser isolation boundary. Prefer rendering
335
+ sanitized output in an <img> with a restrictive CSP and untrusted origin.
336
+ """
337
+ if not isinstance(data, (str, bytes)):
338
+ raise TypeError("data must be str or bytes")
339
+ if len(data if isinstance(data, bytes) else data.encode("utf-8")) > limits.max_input_bytes:
340
+ raise SVGValidationError("SVG input exceeds byte limit")
341
+
342
+ try:
343
+ root = SafeET.fromstring(data, forbid_dtd=True, forbid_entities=True, forbid_external=True)
344
+ except (ET.ParseError, DefusedXmlException, UnicodeError) as exc:
345
+ raise SVGValidationError("Malformed or forbidden SVG/XML document") from exc
346
+
347
+ namespace, name = _tag_parts(root.tag)
348
+ if name != "svg" or namespace not in {None, SVG_NS}:
349
+ raise SVGValidationError("Input must have an SVG root element")
350
+
351
+ _check_complexity(root, limits)
352
+ clean_root = ET.Element(f"{{{SVG_NS}}}svg")
353
+ stack = [(root, clean_root, "svg")]
354
+ while stack:
355
+ original, clean, current_tag = stack.pop()
356
+ for key, value in original.attrib.items():
357
+ # Reject namespaced attributes, including xlink:href and xml:base.
358
+ if key.startswith("{") or ":" in key:
359
+ continue
360
+ if key not in PRESENTATION and key not in GEOMETRY[current_tag]:
361
+ continue
362
+ safe_value = _attribute(key, value, limits)
363
+ if safe_value is not None:
364
+ clean.set(key, safe_value)
365
+
366
+ if current_tag in TEXT_TAGS:
367
+ clean.text = _clean_text(original.text)
368
+
369
+ children_to_visit: list[tuple[ET.Element, ET.Element, str]] = []
370
+ for child in original:
371
+ child_ns, child_name = _tag_parts(child.tag)
372
+ if (child_ns == namespace and child_name in ALLOWED_TAGS
373
+ and _can_contain(current_tag, child_name)):
374
+ child_out = ET.SubElement(clean, f"{{{SVG_NS}}}{child_name}")
375
+ children_to_visit.append((child, child_out, child_name))
376
+
377
+ # 'tail' belongs to the parent text flow, not to a removed element.
378
+ # Preserve that harmless visible text; never copy removed subtree text.
379
+ if current_tag in TEXT_TAGS and child.tail:
380
+ tail = _clean_text(child.tail)
381
+ if len(clean):
382
+ previous = clean[-1]
383
+ previous.tail = (previous.tail or "") + (tail or "")
384
+ else:
385
+ clean.text = (clean.text or "") + (tail or "")
386
+
387
+ stack.extend(reversed(children_to_visit))
388
+
389
+ output = ET.tostring(clean_root, encoding="unicode", short_empty_elements=True)
390
+ if len(output.encode("utf-8")) > limits.max_output_bytes:
391
+ raise SVGValidationError("Sanitized SVG exceeds output byte limit")
392
+ return output
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: svg-sanitize
3
+ Version: 0.1.0
4
+ Summary: Strict, passive-only SVG reconstruction/sanitization prototype
5
+ Author-email: Alexandre Dulaunoy <a@foo.be>
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: defusedxml<1,>=0.7.1
11
+ Provides-Extra: test
12
+ Requires-Dist: pytest>=7; extra == "test"
13
+ Dynamic: license-file
14
+
15
+ # svg-sanitize — restrictive, visual-only SVG sanitizer (prototype)
16
+
17
+ `svg-sanitize` rebuilds untrusted SVG as **static vector graphics only**. It does
18
+ not simply search and replace `script` tags. It parses XML with `defusedxml`,
19
+ checks resource limits, and constructs an entirely new XML tree from a small,
20
+ explicit allowlist of elements, attributes, and valid values.
21
+
22
+ **Prototype, not a formally verified security boundary.** Review, fuzz and test
23
+ against your target SVG renderers before using this in a security-sensitive
24
+ production system.
25
+
26
+ ## Why svg-sanitize?
27
+
28
+ The **svg-sanitize** project started following security reviews conducted by **Jeroen Pinoy**, which highlighted the potential security risks associated with handling untrusted SVG files. This initial prototype was done during the hack.lu 2026 hackathon after the hot ones interview ;-)
29
+
30
+ Although SVG is primarily an image format, it can contain active content, including JavaScript, event handlers, external resource references, and other potentially malicious elements. This makes SVG a potential attack vector, particularly when displaying user-supplied images in web applications.
31
+
32
+ **svg-sanitize** is a lightweight Python library designed to transform untrusted SVG files into passive, visual-only SVG documents. Instead of attempting to identify every possible malicious payload, the library follows a strict allowlist approach, preserving only elements and attributes required for static vector graphics.
33
+
34
+ The objective is to provide a simple, reusable, and open-source security component that reduces the attack surface when processing SVG files, without requiring applications to implement their own SVG sanitization logic.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ python -m pip install -e './svg-sanitize[test]'
40
+ # or, when inside the extracted directory:
41
+ python -m pip install -e '.[test]'
42
+ ```
43
+
44
+ Python 3.10+; the only runtime dependency is `defusedxml`.
45
+
46
+ ## Python usage
47
+
48
+ ```python
49
+ from svg_sanitize import sanitize_svg, SVGValidationError
50
+
51
+ untrusted_svg = b'''<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">
52
+ <script>alert(1)</script>
53
+ <rect width="80" height="40" fill="red"/>
54
+ </svg>'''
55
+
56
+ try:
57
+ passive_svg = sanitize_svg(untrusted_svg)
58
+ except SVGValidationError as exc:
59
+ print(f"SVG rejected: {exc}")
60
+ else:
61
+ print(passive_svg)
62
+ # <svg xmlns="http://www.w3.org/2000/svg"><rect width="80" height="40" fill="red" /></svg>
63
+ ```
64
+
65
+ For custom input and rendering limits:
66
+
67
+ ```python
68
+ from svg_sanitize import Limits, sanitize_svg
69
+
70
+ output = sanitize_svg(untrusted_svg, limits=Limits(max_input_bytes=100_000, max_nodes=1_000))
71
+ ```
72
+
73
+ ## CLI
74
+
75
+ ```bash
76
+ svg-sanitize examples/malicious.svg -o clean.svg
77
+ # alternatively:
78
+ python -m svg_sanitize examples/malicious.svg -o clean.svg
79
+ ```
80
+
81
+ Run tests:
82
+
83
+ ```bash
84
+ python -m pytest -q
85
+ ```
86
+
87
+ ## CI and PyPI releases
88
+
89
+ The [GitHub Actions workflow](.github/workflows/ci.yml) runs the tests on Python
90
+ 3.10–3.14 for pull requests, pushes to `main`, and published GitHub releases.
91
+ After the tests pass, it builds and checks both the source distribution and wheel.
92
+ Only a published release triggers an upload to PyPI, using those checked artifacts.
93
+
94
+ Before the first release:
95
+
96
+ 1. Create a GitHub Actions environment named `pypi` in this repository. Add any
97
+ desired deployment protection rules there.
98
+ 2. Configure a [PyPI trusted publisher](https://docs.pypi.org/trusted-publishers/)
99
+ for the `svg-sanitize` project with owner `adulau`, repository `svg-sanitize`,
100
+ workflow filename `ci.yml`, and environment name `pypi`. If the project does not
101
+ exist yet, configure a pending publisher in your PyPI account instead.
102
+
103
+ No PyPI API token or GitHub repository secret is required. For each release,
104
+ update `project.version` in `pyproject.toml`, merge the change, then create and
105
+ publish a GitHub release from that commit with a matching tag, such as `v0.1.0`
106
+ or `0.1.0`. A mismatched tag fails before publishing. Each upload must use a new
107
+ version because PyPI does not allow replacing previously uploaded files.
108
+ Published prereleases also run this workflow; drafts do not.
109
+
110
+ ## What is allowed
111
+
112
+ Only `svg`, `g`, `rect`, `circle`, `ellipse`, `line`, `polyline`, `polygon`,
113
+ `path`, `text`, and `tspan`. Numeric dimensions and coordinates, path data,
114
+ point lists, limited transforms, limited named/hex/rgb colors, static stroke/
115
+ fill and selected text formatting properties are validated individually.
116
+
117
+ SVGs using gradients, filters, clipping paths, `<use>`, images, embedded fonts,
118
+ CSS, masks, symbols, hyperlinks, animation or scripting **will lose those
119
+ features**. In particular, gradient fills `fill="url(#id)"` are discarded,
120
+ including local URLs. This is an explicit security/functionality tradeoff.
121
+
122
+ ## What is forbidden
123
+
124
+ - JavaScript and scriptable elements; `onload`, `onclick`, and all event handlers.
125
+ - CSS (`<style>`, `style=`), including `@import`, `url(...)`, and expressions.
126
+ - All links and resources, including `href`, `xlink:href`, external and internal
127
+ references, `data:` URLs, `<image>`, `<use>`, fonts, filters and gradients.
128
+ - `<foreignObject>` and arbitrary/HTML/XML namespace elements.
129
+ - SVG animation and SMIL (`animate`, `set`, `animateTransform`, etc.).
130
+ - DTDs, XML entity declarations, external entity references, malformed XML.
131
+ - Unknown attributes (including `id`, `class`, `xml:base`, and `xmlns:xlink`).
132
+
133
+ Unknown elements are removed **with their complete subtrees**; the input is
134
+ never copied wholesale to output. The implementation also enforces limits on
135
+ input bytes, output bytes, node count, depth, attribute sizes and text sizes.
136
+
137
+ ## Deployment advice
138
+
139
+ For untrusted-user uploads, make the sanitized document only one defense layer:
140
+
141
+ 1. Keep the original SVG out of any publicly served directory.
142
+ 2. Serve the sanitized output with `Content-Type: image/svg+xml`, ideally from
143
+ a separate cookieless domain with `Content-Disposition: attachment` for
144
+ downloads. For display, prefer `<img src="...">` rather than inline SVG.
145
+ 3. Use CSP restricting `script-src 'none'; object-src 'none'; base-uri 'none';
146
+ default-src 'none'; img-src 'self' data:` on the serving endpoint where
147
+ appropriate, and ensure application CSP is also restrictive.
148
+ 4. Treat the output as untrusted in any subsequent XML/SVG manipulation pipeline;
149
+ downstream software might add back risky features. If preserving more complex
150
+ SVG features is necessary, consider rasterizing with a hardened, resource-
151
+ isolated renderer instead of broadening the allowlist.
152
+ 5. Use browser-based regression tests, fuzz tests, and multiple-engine testing
153
+ before relying on the package for production security.
154
+
155
+ **No SVG sanitizer can promise protection against all future rendering-engine
156
+ vulnerabilities.** This prototype targets active-content injection and external
157
+ resource-loading pathways, not malicious graphical impersonation or renderer
158
+ memory-corruption bugs.
159
+
160
+ MIT licensed (see `LICENSE`).
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/svg_sanitize/__init__.py
5
+ src/svg_sanitize/__main__.py
6
+ src/svg_sanitize/sanitizer.py
7
+ src/svg_sanitize.egg-info/PKG-INFO
8
+ src/svg_sanitize.egg-info/SOURCES.txt
9
+ src/svg_sanitize.egg-info/dependency_links.txt
10
+ src/svg_sanitize.egg-info/entry_points.txt
11
+ src/svg_sanitize.egg-info/requires.txt
12
+ src/svg_sanitize.egg-info/top_level.txt
13
+ tests/test_sanitizer.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ svg-sanitize = svg_sanitize.__main__:main
@@ -0,0 +1,4 @@
1
+ defusedxml<1,>=0.7.1
2
+
3
+ [test]
4
+ pytest>=7
@@ -0,0 +1 @@
1
+ svg_sanitize
@@ -0,0 +1,192 @@
1
+ import re
2
+ import xml.etree.ElementTree as ET
3
+
4
+ import pytest
5
+
6
+ from svg_sanitize import Limits, SVGValidationError, sanitize_svg
7
+
8
+ SVG = "http://www.w3.org/2000/svg"
9
+ PREFIX = f'<svg xmlns="{SVG}">'
10
+
11
+
12
+ def svg(body: str, extra: str = "") -> str:
13
+ return f'<svg xmlns="{SVG}" {extra}>{body}</svg>'
14
+
15
+
16
+ def tags(xml: str) -> list[str]:
17
+ return [element.tag.split("}")[-1] for element in ET.fromstring(xml).iter()]
18
+
19
+
20
+ def test_basic_geometry_and_presentation_survive():
21
+ source = svg('''<g transform="translate(1, 2)" opacity="0.9">
22
+ <rect width="100" height="50" fill="#abc"/>
23
+ <circle cx="20" cy="20" r="8" stroke="rgb(12, 34, 56)"/>
24
+ <path d="M0 0 L10 10 Z" fill="none" stroke-width="2"/>
25
+ </g>''', 'width="200" height="100" viewBox="0 0 200 100"')
26
+ clean = sanitize_svg(source)
27
+ assert tags(clean) == ["svg", "g", "rect", "circle", "path"]
28
+ assert 'viewBox="0 0 200 100"' in clean
29
+ assert 'transform="translate(1, 2)"' in clean
30
+ assert 'stroke="rgb(12, 34, 56)"' in clean
31
+
32
+
33
+ @pytest.mark.parametrize("payload,needle", [
34
+ ('<script>alert(1)</script><rect width="5" height="5"/>', "script"),
35
+ ('<style>@import url(https://bad.example/a.css)</style>', "style"),
36
+ ('<foreignObject><script xmlns="http://www.w3.org/1999/xhtml">bad()</script></foreignObject>', "foreignObject"),
37
+ ('<a href="javascript:alert(1)"><text>Clickable</text></a>', "a"),
38
+ ('<use href="https://bad.example/x.svg#thing"/>', "use"),
39
+ ('<image href="data:image/svg+xml;base64,xyz"/>', "image"),
40
+ ('<animate attributeName="href" to="javascript:alert(1)"/>', "animate"),
41
+ ('<set attributeName="onload" to="alert(1)"/>', "set"),
42
+ ('<filter><feImage href="https://bad.example/"/></filter>', "filter"),
43
+ ('<defs><linearGradient id="g"/></defs>', "defs"),
44
+ ('<metadata><script>alert(1)</script></metadata>', "metadata"),
45
+ ])
46
+ def test_executable_or_ref_elements_removed(payload, needle):
47
+ result = sanitize_svg(svg(payload))
48
+ assert tags(result) == (["svg", "rect"] if '<rect width="5"' in payload else ["svg"])
49
+ assert needle not in result
50
+ assert "bad.example" not in result
51
+ assert "javascript:" not in result
52
+
53
+
54
+ def test_event_handlers_and_unknown_attrs_removed():
55
+ result = sanitize_svg(svg('<rect width="5" height="6" onload="alert(1)" '
56
+ 'onclick="alert(2)" data-a="1" class="x" id="foo"/>', 'onload="alert(1)"'))
57
+ assert tags(result) == ["svg", "rect"]
58
+ for value in ("onload", "onclick", "alert", "data-a", "class", "id="):
59
+ assert value not in result
60
+
61
+
62
+ def test_discard_css_and_url_values():
63
+ result = sanitize_svg(svg('''<rect width="12" height="13"
64
+ style="fill:url(http://bad.example/img);background:url(javascript:evil())"
65
+ fill="url(https://bad.example/fill.svg#f)"
66
+ stroke="url(#foo)"
67
+ filter="url(https://bad.example/svg#filter)"
68
+ font-family="url(http://bad.example/font.woff)"/>
69
+ '''))
70
+ for item in ("url(", "style=", "filter=", "stroke=", "fill=", "font-family=", "bad.example"):
71
+ assert item not in result
72
+ assert 'width="12"' in result
73
+
74
+
75
+ def test_foreign_and_namespaced_attributes_removed():
76
+ source = f'''<svg xmlns="{SVG}" xmlns:xlink="http://www.w3.org/1999/xlink">
77
+ <rect width="2" height="2" xlink:href="javascript:alert(1)" xml:base="https://bad.example"/>
78
+ <script xmlns="http://www.w3.org/1999/xhtml">evil()</script>
79
+ </svg>'''
80
+ result = sanitize_svg(source)
81
+ assert tags(result) == ["svg", "rect"]
82
+ assert "xlink" not in result and "xml:base" not in result
83
+ assert "bad.example" not in result and "script" not in result
84
+
85
+
86
+ def test_text_and_tspan_survive_escaped():
87
+ result = sanitize_svg(svg('<text x="2" y="14">Hi &amp; <tspan font-weight="bold">&lt;svg&gt;</tspan>!</text>'))
88
+ assert tags(result) == ["svg", "text", "tspan"]
89
+ assert "Hi &amp;" in result
90
+ assert "&lt;svg&gt;" in result
91
+ assert "!</text>" in result
92
+
93
+
94
+ def test_removed_text_descendant_does_not_propagate_active_content():
95
+ output = sanitize_svg(svg('<text>Hello<script>alert(1)</script> World</text>'))
96
+ assert tags(output) == ["svg", "text"]
97
+ assert "Hello World" in output
98
+ assert "alert" not in output
99
+
100
+
101
+ def test_dtd_and_entities_rejected():
102
+ malicious = (f'<!DOCTYPE svg [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>'
103
+ f'<svg xmlns="{SVG}"><text>&xxe;</text></svg>')
104
+ with pytest.raises(SVGValidationError):
105
+ sanitize_svg(malicious)
106
+ with pytest.raises(SVGValidationError):
107
+ sanitize_svg(f'<!DOCTYPE svg><svg xmlns="{SVG}"/>')
108
+
109
+
110
+ def test_processing_instructions_are_not_forwarded():
111
+ doc = f'''<?xml version="1.0"?>
112
+ <?xml-stylesheet href="https://bad.example/style.css" type="text/css"?>
113
+ <svg xmlns="{SVG}"><circle r="3"/></svg>'''
114
+ result = sanitize_svg(doc)
115
+ assert "xml-stylesheet" not in result
116
+ assert "bad.example" not in result
117
+ assert tags(result) == ["svg", "circle"]
118
+
119
+
120
+ def test_invalid_xml_and_wrong_root_rejected():
121
+ for payload in ("<svg><g></svg>", "<html></html>", '<svg xmlns="http://evil.example/svg"/>'):
122
+ with pytest.raises(SVGValidationError):
123
+ sanitize_svg(payload)
124
+
125
+
126
+ def test_resource_limits():
127
+ with pytest.raises(SVGValidationError):
128
+ sanitize_svg(svg(' ' * 200), limits=Limits(max_input_bytes=30))
129
+ with pytest.raises(SVGValidationError):
130
+ sanitize_svg(svg('<g/>' * 5), limits=Limits(max_nodes=3))
131
+ with pytest.raises(SVGValidationError):
132
+ sanitize_svg(svg('<g><g><g/></g></g>'), limits=Limits(max_depth=3))
133
+ with pytest.raises(SVGValidationError):
134
+ sanitize_svg(svg('<text>123456</text>'), limits=Limits(max_text_chars=4))
135
+ with pytest.raises(SVGValidationError):
136
+ sanitize_svg(svg('<rect width="2" height="3"/>'), limits=Limits(max_output_bytes=20))
137
+
138
+
139
+ def test_invalid_geometry_is_dropped():
140
+ result = sanitize_svg(svg('<rect x="1e309" width="-5" height="50" opacity="2" '
141
+ 'transform="translate(0);evil()" fill="expression(alert(1))"/>'))
142
+ assert tags(result) == ["svg", "rect"]
143
+ assert 'height="50"' in result
144
+ assert all(x not in result for x in (' x=', 'width=', 'opacity=', 'transform=', 'fill=', 'evil'))
145
+
146
+
147
+ def test_path_and_polyline_validation():
148
+ good = sanitize_svg(svg('<path d="M0 0 L50 50 Z"/><polyline points="0,0 20,20 40,0"/>'))
149
+ assert 'd="M0 0 L50 50 Z"' in good and 'points="0,0 20,20 40,0"' in good
150
+ bad = sanitize_svg(svg('<path d="M0 0 javascript:alert(1)"/><polygon points="0,0 1,evil"/>'))
151
+ assert ' d=' not in bad and ' points=' not in bad
152
+
153
+
154
+ def test_font_family_whitelist():
155
+ result = sanitize_svg(svg('<text font-family="Arial, sans-serif">A</text>'
156
+ '<text font-family="local(https://bad.example)">B</text>'))
157
+ assert 'font-family="Arial, sans-serif"' in result
158
+ assert "bad.example" not in result
159
+
160
+
161
+ def test_non_namespaced_input_is_rewritten_into_svg_namespace():
162
+ cleaned = sanitize_svg('<svg><rect width="10" height="20"/></svg>')
163
+ assert f'xmlns="{SVG}"' in cleaned
164
+ assert tags(cleaned) == ["svg", "rect"]
165
+
166
+
167
+ def test_idempotence():
168
+ doc = svg('<g transform="rotate(20 10 10)"><circle r="15" fill="blue"/></g>'
169
+ '<text x="20" y="30">A &amp; B</text>')
170
+ assert sanitize_svg(sanitize_svg(doc)) == sanitize_svg(doc)
171
+
172
+
173
+ def test_no_suspicious_active_content_can_survive_sample():
174
+ from pathlib import Path
175
+ input_file = Path(__file__).parent.parent / "examples" / "malicious.svg"
176
+ result = sanitize_svg(input_file.read_bytes())
177
+ assert tags(result) == ["svg", "rect", "text"]
178
+ for danger in ("script", "style", "foreignObject", "image", "alert(", "onload", "onclick",
179
+ "http://example.invalid", "href", "url(", "javascript:"):
180
+ assert danger not in result
181
+ assert "Harmless visible text" in result
182
+
183
+
184
+ def test_control_bidi_removed_from_visible_text():
185
+ clean = sanitize_svg(svg('<text>ABC\u202eDEF</text>'))
186
+ assert "ABCDEF" in clean and "\u202e" not in clean
187
+
188
+
189
+ def test_types():
190
+ with pytest.raises(TypeError):
191
+ sanitize_svg(10)
192
+ assert '<circle' in sanitize_svg(svg('<circle r="5"/>').encode("utf-8"))