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.
- svg_sanitize-0.1.0/LICENSE +21 -0
- svg_sanitize-0.1.0/PKG-INFO +160 -0
- svg_sanitize-0.1.0/README.md +146 -0
- svg_sanitize-0.1.0/pyproject.toml +25 -0
- svg_sanitize-0.1.0/setup.cfg +4 -0
- svg_sanitize-0.1.0/src/svg_sanitize/__init__.py +5 -0
- svg_sanitize-0.1.0/src/svg_sanitize/__main__.py +31 -0
- svg_sanitize-0.1.0/src/svg_sanitize/sanitizer.py +392 -0
- svg_sanitize-0.1.0/src/svg_sanitize.egg-info/PKG-INFO +160 -0
- svg_sanitize-0.1.0/src/svg_sanitize.egg-info/SOURCES.txt +13 -0
- svg_sanitize-0.1.0/src/svg_sanitize.egg-info/dependency_links.txt +1 -0
- svg_sanitize-0.1.0/src/svg_sanitize.egg-info/entry_points.txt +2 -0
- svg_sanitize-0.1.0/src/svg_sanitize.egg-info/requires.txt +4 -0
- svg_sanitize-0.1.0/src/svg_sanitize.egg-info/top_level.txt +1 -0
- svg_sanitize-0.1.0/tests/test_sanitizer.py +192 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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 & <tspan font-weight="bold"><svg></tspan>!</text>'))
|
|
88
|
+
assert tags(result) == ["svg", "text", "tspan"]
|
|
89
|
+
assert "Hi &" in result
|
|
90
|
+
assert "<svg>" 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 & 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"))
|