blob: 6323cc40fbac8803b2c881aa330331f8667c3d4f [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0.
from __future__ import annotations
import importlib.util
import json
import os
import pathlib
import shutil
import subprocess
import sys
import tempfile
import unittest
import urllib.parse
VALIDATOR_PATH = pathlib.Path(__file__).parents[1] / "dist/validate-site-output.py"
SPEC = importlib.util.spec_from_file_location("validate_site_output", VALIDATOR_PATH)
assert SPEC and SPEC.loader
VALIDATOR = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(VALIDATOR)
HUGO_BIN = os.environ.get("HUGO_BIN", "hugo")
BASE = urllib.parse.urlsplit("https://hugegraph-oink.staged.apache.org/")
def parse(fragment: str):
parser = VALIDATOR.DocumentParser()
parser.feed(fragment)
return parser
class SiteOutputSecurityTest(unittest.TestCase):
def test_docs_navigation_requires_five_localized_populated_groups(self) -> None:
for language in ("en", "cn"):
groups = [
{
"title": title,
"kind": "external",
"url": f"https://example.org/{language}/docs/{index}/",
"children": [{"title": "child"}],
}
for index, title in enumerate(VALIDATOR.DOCS_NAV_GROUP_TITLES[language])
]
nav = {"root": {"children": [{"id": "/docs/", "children": groups}]}}
with self.subTest(language=language):
self.assertEqual(
VALIDATOR.docs_navigation_errors(
nav, f"{language}/navigation.json", language
),
[],
)
groups[0]["url"] = "https://example.org/docs/_nav/start/"
self.assertIn(
"private Docs navigation route leaked",
"\n".join(VALIDATOR.docs_navigation_errors(nav, "navigation.json", "cn")),
)
def test_toc_accessibility_accepts_empty_and_populated_localized_navs(self) -> None:
fixtures = (
(
"docs/config/index.html",
'<nav id="TableOfContents" aria-label="Content"></nav>',
),
(
"cn/docs/config/index.html",
'<nav id="TableOfContents" aria-label="目录">'
'<ul><li><a href="#server">Server</a></li></ul></nav>',
),
(
"versions/1.7/cn/docs/config/index.html",
'<nav id="TableOfContents" aria-label="目录"></nav>',
),
)
for page_name, fragment in fixtures:
with self.subTest(page_name=page_name):
self.assertEqual(
VALIDATOR.toc_accessibility_errors(parse(fragment), page_name), []
)
self.assertEqual(
VALIDATOR.toc_accessibility_errors(
parse("<main>No table of contents</main>"), "docs/short/index.html"
),
[],
)
def test_toc_accessibility_rejects_missing_wrong_and_duplicate_labels(self) -> None:
fixtures = (
(
'<nav id="TableOfContents"></nav>',
"docs/config/index.html: TableOfContents nav must have exactly one "
"localized aria-label 'Content', found []",
),
(
'<nav id="TableOfContents" aria-label="Content"></nav>',
"cn/docs/config/index.html: TableOfContents nav must have exactly one "
"localized aria-label '目录', found ['Content']",
),
(
'<nav id="TableOfContents" aria-label="Content" '
'aria-label="Content"></nav>',
"docs/config/index.html: TableOfContents nav must have exactly one "
"localized aria-label 'Content', found ['Content', 'Content']",
),
)
for fragment, expected in fixtures:
page_name = (
"cn/docs/config/index.html"
if "localized aria-label '目录'" in expected
else "docs/config/index.html"
)
with self.subTest(fragment=fragment):
self.assertEqual(
VALIDATOR.toc_accessibility_errors(parse(fragment), page_name),
[expected],
)
def test_error_documents_are_noindex_without_canonical_or_hreflang(self) -> None:
parser = parse(
'<meta name="robots" content="noindex, nofollow"><main>Not found</main>'
)
for page_name in (
"404.html",
"cn/404.html",
"versions/1.7/404.html",
"versions/1.7/cn/404.html",
):
with self.subTest(page_name=page_name):
self.assertEqual(
VALIDATOR.error_document_seo_errors(parser, page_name), []
)
def test_error_document_seo_rejects_indexing_and_url_claims(self) -> None:
parser = parse(
'<meta name="robots" content="index, follow">'
'<link rel="canonical" href="https://example.com/404.html">'
'<link rel="alternate" hreflang="en-US" '
'href="https://example.com/404.html">'
)
self.assertEqual(
VALIDATOR.error_document_seo_errors(parser, "versions/1.5/cn/404.html"),
[
"versions/1.5/cn/404.html: expected one robots "
"noindex,nofollow directive, found ['index, follow']",
"versions/1.5/cn/404.html: error document must not declare canonical",
"versions/1.5/cn/404.html: error document must not declare hreflang",
],
)
def test_error_document_paths_follow_all_manifest_versions(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
manifest = {
"versions": [
{"id": "latest", "publishPath": ""},
{"id": "1.7", "publishPath": "versions/1.7"},
{"id": "1.5", "publishPath": "versions/1.5"},
{"id": "1.3", "publishPath": "versions/1.3"},
{"id": "1.0", "publishPath": "versions/1.0"},
]
}
metadata = root / "build-metadata"
metadata.mkdir()
(metadata / "versions.json").write_text(
json.dumps(manifest), encoding="utf-8"
)
self.assertEqual(
VALIDATOR.error_document_paths(root),
{
"404.html",
"cn/404.html",
"versions/1.7/404.html",
"versions/1.7/cn/404.html",
"versions/1.5/404.html",
"versions/1.5/cn/404.html",
"versions/1.3/404.html",
"versions/1.3/cn/404.html",
"versions/1.0/404.html",
"versions/1.0/cn/404.html",
},
)
def test_all_modes_reject_old_version_error_page_seo(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
manifest = {
"versions": [
{"id": "latest", "publishPath": ""},
{"id": "1.3", "publishPath": "versions/1.3"},
{"id": "1.0", "publishPath": "versions/1.0"},
]
}
metadata = root / "build-metadata"
metadata.mkdir()
(metadata / "versions.json").write_text(
json.dumps(manifest), encoding="utf-8"
)
unsafe = (
'<meta name="robots" content="index, follow">'
'<link rel="canonical" href="https://example.com/404.html">'
'<link rel="alternate" hreflang="en-US" '
'href="https://example.com/404.html">'
)
for version in ("1.3", "1.0"):
for language in ("", "cn/"):
page = root / f"versions/{version}/{language}404.html"
page.parent.mkdir(parents=True, exist_ok=True)
page.write_text(unsafe, encoding="utf-8")
for extra_args in (["--security-only"], []):
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/",
*extra_args,
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
for version in ("1.3", "1.0"):
for language in ("", "cn/"):
page_name = f"versions/{version}/{language}404.html"
with self.subTest(mode=extra_args, page_name=page_name):
self.assertIn(
f"{page_name}: expected one robots noindex,nofollow",
result.stdout,
)
self.assertIn(
f"{page_name}: error document must not declare canonical",
result.stdout,
)
self.assertIn(
f"{page_name}: error document must not declare hreflang",
result.stdout,
)
def test_nested_content_404_is_not_an_error_document_exception(self) -> None:
parser = parse(
'<meta name="robots" content="index, follow">'
'<link rel="canonical" href="https://example.com/docs/404.html">'
)
self.assertNotIn("docs/404.html", VALIDATOR.ERROR_DOCUMENT_PATHS)
self.assertEqual(
VALIDATOR.error_document_seo_errors(parser, "docs/404.html"), []
)
def test_security_only_mode_skips_aggregate_url_contracts(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
(root / "index.html").write_text(
'<main><a href="/versions/1.7/docs/">1.7</a></main>'
'<nav id="TableOfContents"></nav>',
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/versions/1.5/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
full_result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/versions/1.5/",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(full_result.returncode, 1)
self.assertIn(
"TableOfContents nav must have exactly one localized aria-label",
full_result.stdout,
)
(root / "index.html").write_text(
"<main><script>alert(1)</script></main>", encoding="utf-8"
)
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/versions/1.5/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1)
self.assertIn("authored <script>", result.stdout)
(root / "index.html").write_text("<main>safe</main>", encoding="utf-8")
(root / "site.css").write_text(
".hero{background:url(https://evil.example/hero.png)}",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/versions/1.5/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1)
self.assertIn("external CSS resource is forbidden", result.stdout)
def test_security_workers_preserve_cli_results(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
def run(*options: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, str(VALIDATOR_PATH), str(root),
"https://hugegraph.apache.org/", *options],
check=False, capture_output=True,
)
def assert_same_results(expected_code: int) -> bytes:
default = run("--security-only")
self.assertEqual(default.returncode, expected_code, default.stderr)
for workers in ("1", "2"):
result = run("--security-only", "--workers", workers)
self.assertEqual(
(result.returncode, result.stdout, result.stderr),
(default.returncode, default.stdout, default.stderr),
)
return default.stdout
(root / "index.html").write_text("<main>safe</main>", encoding="utf-8")
(root / "404.html").write_text(
'<meta name="robots" content="noindex, nofollow">', encoding="utf-8"
)
(root / "site.css").write_text("body{color:black}", encoding="utf-8")
assert_same_results(0)
(root / "a-invalid-url.html").write_text(
'<main><a href="https://[invalid">bad</a>'
'<script>shape error must short-circuit</script></main>',
encoding="utf-8",
)
(root / "b-invalid-utf8.html").write_bytes(b"<main>\xff</main>")
(root / "c-unsafe.html").write_text(
"<main><script>alert(1)</script></main>", encoding="utf-8"
)
(root / "404.html").write_text(
'<meta name="robots" content="index, follow">', encoding="utf-8"
)
(root / "site.css").write_text(
'body{background:url("missing.png")}', encoding="utf-8"
)
output = assert_same_results(1)
for diagnostic in (
b"malformed URL", b"cannot parse b-invalid-utf8.html",
b"c-unsafe.html: unsafe content markup: authored <script>",
b"404.html: expected one robots noindex,nofollow",
b"broken internal CSS resource missing.png",
):
self.assertIn(diagnostic, output)
self.assertNotIn(b"a-invalid-url.html: unsafe content markup", output)
# The option must not alter the normal full-contract validation path.
normal = run()
self.assertEqual(normal.returncode, 1)
self.assertIn(b"missing required output", normal.stdout)
for workers in ("1", "2"):
result = run("--workers", workers)
self.assertEqual(
(result.returncode, result.stdout, result.stderr),
(normal.returncode, normal.stdout, normal.stderr),
)
def test_shell_scripts_outside_content_are_allowed(self) -> None:
parser = parse(
'<head><script src="/shell.js"></script></head>'
"<main><p>Documentation</p></main>"
'<script id="td-action-manifest" type="application/json">{}</script>'
)
self.assertEqual(parser.authored_violations, [])
def test_active_markup_and_event_handlers_inside_content_are_rejected(self) -> None:
parser = parse(
'<main onclick="run()"><script>alert(1)</script>'
'<iframe src="/frame"></iframe><object data="/object"></object>'
'<embed src="/embed"><article><img src="/ok.png" onerror="run()">'
"</article></main>"
)
self.assertEqual(
parser.authored_violations,
[
"authored onclick event attribute on <main>",
"authored <script>",
"authored <iframe>",
"authored <object>",
"authored <embed>",
"authored onerror event attribute on <img>",
],
)
def test_duplicate_attributes_are_rejected_case_insensitively(self) -> None:
parser = parse(
'<img src="/safe.png" SRC="https://evil.example/image.png">'
'<svg><use xlink:href="#safe" XLINK:HREF="https://evil.example/x">'
"</use></svg>"
)
self.assertEqual(
parser.authored_violations,
[
"duplicate src attribute on <img>",
"duplicate xlink:href attribute on <use>",
],
)
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE)
self.assertIn(
"index.html: unsafe content markup: duplicate src attribute on <img>",
errors,
)
self.assertIn(
"index.html: unsafe content markup: duplicate xlink:href attribute "
"on <use>",
errors,
)
def test_exact_oink_diagram_source_is_allowed_inside_content(self) -> None:
parser = parse(
'<main><script type="application/json" data-td-diagram-source>'
'{"code":"graph TD; A-->B"}</script></main>'
)
self.assertEqual(parser.authored_violations, [])
def test_other_json_and_diagram_marked_scripts_are_rejected(self) -> None:
rejected = [
'<script type="application/json">{}</script>',
"<script data-td-diagram-source>{}</script>",
'<script type="text/javascript" data-td-diagram-source></script>',
'<script type="application/json" data-td-diagram-source '
'src="/payload.json"></script>',
'<script type="application/json" data-td-diagram-source '
'data-extra="true"></script>',
]
for script in rejected:
with self.subTest(script=script):
parser = parse(f"<article>{script}</article>")
self.assertIn("authored <script>", parser.authored_violations)
def test_exact_oink_chart_and_terminal_json_carriers_are_allowed(self) -> None:
# Shapes emitted by tagged OINK 1.2 render-codeblock-echarts.html and
# _shortcodes/asciinema.html, including their native wrapper markup.
fragments = [
'<div id="td-echarts-placeholder-f0" class="td-echarts td-max-width-on-larger-screens" data-td-echarts>'
'<div data-td-echarts-canvas style="height:360px"></div>'
'<script type="application/json" data-td-echarts-options>'
'{"series":[{"type":"bar","data":[1,2]}]}</script></div>',
'<div id="td-asciinema-placeholder-0" class="td-asciinema td-max-width-on-larger-screens" '
'data-td-asciinema data-td-timer-label="Playback time">'
'<div class="td-asciinema__chrome"><span class="td-asciinema__title" dir="auto">Demo</span></div>'
'<div data-td-asciinema-player></div>'
'<script type="application/json" data-td-asciinema-config>'
'{"src":"/recording.cast","theme":"auto","options":{"autoPlay":false,"speed":1}}</script></div>',
]
for fragment in fragments:
with self.subTest(fragment=fragment):
parser = parse(f"<main><article>{fragment}</article></main>")
self.assertEqual(parser.authored_violations, [])
self.assertEqual(VALIDATOR.document_security_errors(parser, "index.html", BASE), [])
def test_component_json_carrier_disguises_and_breakouts_are_rejected(self) -> None:
for marker in (
"data-td-diagram-source", "data-td-echarts-options", "data-td-asciinema-config",
"data-td-table-labels", "data-td-diagram-labels",
):
variants = [
f'<script {marker}>alert(1)</script>',
f'<script type="module" {marker}>alert(1)</script>',
f'<script type="text/javascript" {marker}>alert(1)</script>',
f'<script type="application/json" {marker}="true">{{}}</script>',
f'<script type="application/json" {marker} src="/evil.js">{{}}</script>',
f'<script type="application/json" {marker} onload="evil()">{{}}</script>',
f'<script type="application/json" {marker} class="disguise">{{}}</script>',
f'<script type="application/json" {marker} data-extra="true">{{}}</script>',
f'<script type="application/json" {marker} data-td-fake-config>{{}}</script>',
f'<script type="application/json" {marker}>{{"text":"</script><script>alert(1)</script>"}}',
'<script type="application/json" data-td-fake-config>{}</script>',
]
for variant in variants:
with self.subTest(marker=marker, variant=variant):
parser = parse(f"<article>{variant}</article>")
self.assertIn("authored <script>", parser.authored_violations)
duplicate = f'<script type="application/json" {marker} {marker}>{{}}</script>'
parser = parse(f"<article>{duplicate}</article>")
self.assertTrue(any("duplicate" in violation for violation in parser.authored_violations))
def test_oink_reader_labels_are_inert_text_in_native_dialogs(self) -> None:
fixtures = [
(
"data-td-table-labels",
'id="td-table-reader" data-td-table-reader',
{"title": "展开表格", "viewport": "可滚动的表格"},
),
(
"data-td-diagram-labels",
"data-td-diagram-zoom-dialog",
{
"source": "源码",
"expand": "展开图表",
"copy": "复制",
"copied": "已复制",
"copyError": "复制失败",
"error": "图表不可用",
"downloadStarted": "下载已开始",
"downloadError": "下载失败",
},
),
]
for marker, dialog, payload in fixtures:
with self.subTest(marker=marker):
# Translation strings remain text even when they contain markup
# or URL-looking text. Hugo's jsonify escapes the script body.
first_key = next(iter(payload))
payload[first_key] += ' <img src="https://example.org/text.png">'
encoded = json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e")
parser = parse(
"<main><p>Documentation</p></main>"
f'<dialog {dialog}><script type="application/json" {marker}>'
+ encoded + "</script></dialog>"
)
self.assertEqual(parser.authored_violations, [])
self.assertEqual(parser.resources, [])
self.assertEqual(VALIDATOR.document_security_errors(parser, "index.html", BASE), [])
def test_oink_reader_labels_reject_invalid_payloads(self) -> None:
fixtures = [
("data-td-table-labels", {"title": "Table", "viewport": "Scrollable table"}),
(
"data-td-diagram-labels",
{
"source": "Source", "expand": "Expand", "copy": "Copy", "copied": "Copied",
"copyError": "Copy failed", "error": "Unavailable",
"downloadStarted": "Downloaded", "downloadError": "Download failed",
},
),
]
for marker, payload in fixtures:
first_key = next(iter(payload))
missing = dict(payload)
missing.pop(first_key)
invalid = [
"invalid JSON",
json.dumps([]),
json.dumps(missing),
json.dumps(dict(payload, src="https://example.org/unexpected.json")),
*(json.dumps(dict(payload, **{first_key: value})) for value in (None, 0, [], {})),
]
for encoded in invalid:
with self.subTest(marker=marker, payload=encoded):
# Dialog configuration lives outside main/article too.
parser = parse(
f'<dialog><script type="application/json" {marker}>'
+ encoded + "</script></dialog>"
)
self.assertTrue(parser.authored_violations)
def test_reader_label_carrier_shapes_are_checked_outside_content(self) -> None:
for marker in ("data-td-table-labels", "data-td-diagram-labels"):
variants = [
f'<div type="application/json" {marker}>{{}}</div>',
f'<script type="application/json" {marker}="true">{{}}</script>',
f'<script type="application/json" {marker} src="/evil.js">{{}}</script>',
f'<script type="application/json" {marker} class="disguise">{{}}</script>',
]
for variant in variants:
with self.subTest(marker=marker, variant=variant):
parser = parse("<dialog>" + variant + "</dialog>")
self.assertTrue(any("noncanonical OINK component configuration" in violation
for violation in parser.authored_violations))
def test_asciinema_payload_source_uses_active_resource_boundary(self) -> None:
for source in ("https://evil.example/x.cast", "http://evil.example/x.cast", "//evil.example/x.cast",
"javascript:evil()", "data:text/plain,cast", "\\\\evil.example\\x.cast"):
with self.subTest(source=source):
parser = parse('<article><script type="application/json" data-td-asciinema-config>' +
json.dumps({"src": source, "theme": "auto", "options": {"autoPlay": False}}) + "</script></article>")
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE) + \
VALIDATOR.document_url_shape_errors(parser, "index.html")
self.assertTrue(errors)
parser = parse('<article><script type="application/json" data-td-asciinema-config>' +
json.dumps({"src": "/recording.cast", "theme": "auto", "options": {"autoPlay": False}}) + "</script></article>")
self.assertEqual(VALIDATOR.document_security_errors(parser, "index.html", BASE), [])
self.assertEqual(VALIDATOR.document_url_shape_errors(parser, "index.html"), [])
def test_echarts_payload_images_and_formatter_html_are_validated(self) -> None:
payloads = [
{"series": [{"symbol": "image://https://evil.example/symbol.png"}]},
{"graphic": {"style": {"image": "https://evil.example/image.png"}}},
{"textStyle": {"rich": {"a": {"backgroundColor": {"image": "//evil.example/image.png"}}}}},
{"tooltip": {"formatter": '<img src="https://evil.example/x.png">'}},
{"tooltip": {"formatter": '<img src="/ok.png" onerror="evil()">'}},
{"tooltip": {"formatter": '</article><script>evil()</script>'}},
{"tooltip": {"formatter": '<template data-hg-authored-content="end"></template>'
'<script>evil()</script><template data-hg-authored-content="start"></template>'}},
{"tooltip": {"formatter": '<iframe src="/frame"></iframe>'}},
{"tooltip": {"formatter": '<a href="javascript:evil()">link</a>'}},
{"tooltip": {"formatter": '<div style="background:url(https://evil.example/x)">x</div>'}},
]
for payload in payloads:
with self.subTest(payload=payload):
# Hugo's jsonify escapes HTML characters in the script body;
# exercise runtime formatter markup after JSON decoding.
encoded = json.dumps(payload).replace("<", "\\u003c").replace(">", "\\u003e")
parser = parse('<article><script type="application/json" data-td-echarts-options>' +
encoded + "</script></article>")
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE) + \
VALIDATOR.document_url_shape_errors(parser, "index.html")
self.assertTrue(errors)
for payload in ({"tooltip": {"formatter": "<b>{b}</b>: {c}"}},
{"tooltip": {"trigger": "axis"}, "series": [{"type": "bar", "data": [1, 2]}]},
{"series": [{"symbol": "image:///local.png"}]}):
parser = parse('<article><script type="application/json" data-td-echarts-options>' +
json.dumps(payload) + "</script></article>")
self.assertEqual(VALIDATOR.document_security_errors(parser, "index.html", BASE), [])
def test_reserved_component_markers_require_exact_carrier_on_every_element(self) -> None:
for marker in VALIDATOR.OINK_CONFIG_MARKERS:
for tag in ("pre", "div", "template", "span"):
for attributes in (marker, f'{marker}="true"', f'{marker} data-extra="true"'):
with self.subTest(marker=marker, tag=tag, attributes=attributes):
parser = parse(f'<{tag} {attributes}>{{"src":"https://evil.example/cast"}}</{tag}>')
self.assertTrue(VALIDATOR.document_security_errors(parser, "index.html", BASE))
def test_echarts_runtime_sink_paths_and_inert_dataset_columns(self) -> None:
unsafe = [
{"tooltip": {"extraCssText": "background:url(https://evil.example/a.png)"}},
{"series": [{"tooltip": {"extraCssText": "background:url(//evil.example/a.png)"}}]},
{"tooltip": {"extraCssText": "background:url(javascript:evil())"}},
{"toolbox": {"feature": {"dataView": {"lang": ['<img src="/ok.png" onerror="evil()">', "Close", "Refresh"]}}}},
{"toolbox": {"feature": {"dataView": {"title": '<img src="https://evil.example/a.png">'}}}},
{"toolbox": {"feature": {"dataView": {"lang": {"0": '<img src="/ok.png" onerror="evil()">'}}}}},
{"title": [{"link": "javascript:evil()", "target": "self"}]},
{"title": {"sublink": "data:text/html,evil"}},
{"title": {"link": ["javascript:evil()"]}},
{"tooltip": {"extraCssText": ["background:url(https://evil.example/a.png)"]}},
{"series": [{"type": "treemap", "data": [{"link": "javascript:evil()"}]}]},
{"toolbox": {"feature": {"saveAsImage": {"lang": ['" onerror="evil()']}}}},
]
safe = [
{"dataset": {"source": [{"url": "https://external.example/data", "image": "https://external.example/a.png",
"formatter": '<img src="/ok.png" onerror="evil()">', "value": 42}]}},
{"title": {"link": "https://example.org/docs", "sublink": "/docs/"}},
{"tooltip": {"extraCssText": "background:url(/local.png);padding:4px"}},
{"toolbox": {"feature": {"dataView": {"lang": ["<b>Values</b>", "Close", "Refresh"]}}}},
]
for payload, rejected in [(payload, True) for payload in unsafe] + [(payload, False) for payload in safe]:
with self.subTest(payload=payload):
parser = parse('<article><script type="application/json" data-td-echarts-options>' +
json.dumps(payload).replace("<", "\\u003c") + '</script></article>')
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE) + \
VALIDATOR.document_url_shape_errors(parser, "index.html")
self.assertEqual(bool(errors), rejected, errors)
def test_echarts_tooltip_shorthand_inherited_fonts_and_fallback_colors(self) -> None:
hostile = '<img src="/missing" onerror="evil()">'
unsafe = [
{"series": [{"tooltip": hostile}]}, {"series": [{"data": [{"value": 5, "tooltip": hostile}]}]},
{"textStyle": {"fontFamily": "serif;background:url(https://evil.example/font.png)"}},
{"visualMap": {"pieces": [{"min": 0, "max": 10, "color": "red;background:url(https://evil.example/color.png)"}]}},
{"tooltip": {"defaultBorderColor": "red;background:url(https://evil.example/border.png)"}},
]
safe = [
{"series": [{"tooltip": "<b>{b}</b>: {c}"}]},
{"series": [{"data": [{"value": 5, "tooltip": "{b}: {c}"}]}]},
{"textStyle": {"fontFamily": 'Arial, "思源黑体", sans-serif', "fontSize": 14}},
{"visualMap": {"pieces": [{"color": {"type": "linear", "colorStops": [{"offset": 0, "color": "red"}]}}]}},
{"tooltip": {"defaultBorderColor": "rgba(0,0,0,.5)"}},
{"dataset": {"source": [{"tooltip": hostile, "textStyle": {"fontFamily": unsafe[2]["textStyle"]["fontFamily"]}}]}},
]
for option, rejected in [(option, True) for option in unsafe] + [(option, False) for option in safe]:
with self.subTest(option=option):
parser = parse('<script type="application/json" data-td-echarts-options>' +
json.dumps(option).replace("<", "\\u003c") + '</script>')
self.assertEqual(bool(VALIDATOR.document_security_errors(parser, "index.html", BASE)), rejected)
def test_enabled_markmap_uses_explicit_plain_text_contract(self) -> None:
import html
runtime = '<script src="/third_party/markmap/markmap-autoloader.hash.js"></script>'
plain = "---\n# layout comment\nmarkmap:\n spacingHorizontal: 40 # layout\n---\n# HugeGraph\n- server\n- `core`"
for source in [plain, "# Outline\n- `![image](https://evil.example/code.png)`", "# Outline\n```text\n![image](https://evil.example/code.png)\n```", "# Outline\n- &lt;img src=/literal&gt;"]:
parser = parse('<pre><code class="language-markmap">' + html.escape(source) + '</code></pre>' + runtime)
self.assertEqual(VALIDATOR.document_security_errors(parser, "index.html", BASE), [], source)
blocked = ["# Parent\n- ![image](https://evil.example/map.png)", "# Parent\n- ![image](/img/allowed.png)",
"# Parent\n- ![image][ref]\n[ref]: /img/allowed.png", "# Parent\n- <img src=/allowed.png onerror=evil()>",
"---\nmarkmap:\n extraJs: [/local.js]\n---\n# Parent", "---\ntitle: '<img src=/allowed.png>'\n---",
"# Parent\n- [link](/docs/)"]
for source in blocked:
with self.subTest(source=source):
encoded = '<pre><code class="language-markmap">' + html.escape(source) + '</code></pre>'
parser = parse(encoded + runtime)
self.assertTrue(VALIDATOR.document_security_errors(parser, "index.html", BASE))
# Disabled runtime and ordinary Markdown demonstrations remain
# inert, even if their displayed text contains an image URL.
self.assertEqual(VALIDATOR.document_security_errors(parse(encoded), "index.html", BASE), [])
ordinary = encoded.replace("language-markmap", "language-markdown")
self.assertEqual(VALIDATOR.document_security_errors(parse(ordinary + runtime), "index.html", BASE), [])
@unittest.skipUnless(shutil.which(HUGO_BIN), "Hugo is required to render the pinned Markmap fixture")
def test_native_markmap_runtime_on_off_obeys_plain_text_contract(self) -> None:
repo = VALIDATOR_PATH.parents[1]
with tempfile.TemporaryDirectory(prefix="oink-markmap-security-") as temporary:
root = pathlib.Path(temporary)
for name in ("go.mod", "go.sum"):
shutil.copyfile(repo / name, root / name)
(root / "layouts").mkdir()
(root / "layouts/single.html").write_text('<article>{{ .Content }}</article>{{ partial "scripts.html" . }}')
(root / "content").mkdir()
sources = [
("# HugeGraph\n- server\n- core", False),
("---\nmarkmap:\n spacingHorizontal: 40\n---\n# HugeGraph\n- server", False),
("# Parent\n- ![image](/img/local.png)", True),
("# Parent\n- ![image][ref]\n\n[ref]: https://evil.example/ref.png", True),
# This pinned hook double-escapes raw HTML; textContent still
# contains &lt; so MarkdownIt renders literal text, not an img.
("# Parent\n- <img src=/local.png onerror=evil()>", False),
("---\nmarkmap:\n extraJs: [/local.js]\n---\n# Parent", True),
("# Parent\n- `![image](https://evil.example/code.png)`", False),
]
for index, (source, _) in enumerate(sources):
(root / f"content/map-{index}.md").write_text('---\ntitle: Map fixture\n---\n```markmap\n' + source + '\n```\n')
artifact = root / "artifact"
artifact.mkdir()
for enabled in (True, False):
(root / "hugo.yaml").write_text(
f'baseURL: {BASE.geturl()}\nparams:\n markmap: {str(enabled).lower()}\n'
'module:\n imports:\n - path: github.com/pgsty/oink\n')
result = subprocess.run([shutil.which(HUGO_BIN), "--source", str(root), "--destination", str(root / "public"),
"--environment", "production", "--logLevel", "info"], text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for index, (_, rejected) in enumerate(sources):
with self.subTest(enabled=enabled, source=index):
shutil.copyfile(root / f"public/map-{index}/index.html", artifact / "index.html")
result = subprocess.run([sys.executable, str(VALIDATOR_PATH), str(artifact), BASE.geturl(), "--security-only"],
text=True, capture_output=True)
self.assertEqual(bool(result.returncode), enabled and rejected, result.stdout + result.stderr)
def test_echarts_effective_option_roots_reuse_all_sink_checks(self) -> None:
wrappers = [lambda option: option, lambda option: {"baseOption": option},
lambda option: {"baseOption": {}, "options": [option]},
lambda option: {"baseOption": {}, "options": {"0": option}},
lambda option: {"media": [{"query": {"maxWidth": 600}, "option": option}]},
lambda option: {"media": [{"option": option}]}]
unsafe_options = [
{"title": {"link": "javascript:evil()"}},
{"toolbox": {"feature": {"dataView": {"lang": ['<img src="/missing" onerror="evil()">']}}}},
{"toolbox": {"feature": {"saveAsImage": {"lang": ['unsafe fallback']}}}},
{"tooltip": {"backgroundColor": 'red;"><img src=/missing onerror=evil()>'}},
]
safe = {"dataset": {"source": [{"symbol": "image://https://external.example/ordinary-data",
"color": {"image": "https://external.example/ordinary-data"}}]},
"tooltip": {"backgroundColor": "rgba(255, 255, 255, 0.8)"}}
for wrap in wrappers:
for option, rejected in [(option, True) for option in unsafe_options] + [(safe, False)]:
with self.subTest(payload=wrap(option)):
parser = parse('<article><script type="application/json" data-td-echarts-options>' +
json.dumps(wrap(option)).replace("<", "\\u003c") + '</script></article>')
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE) + \
VALIDATOR.document_url_shape_errors(parser, "index.html")
self.assertEqual(bool(errors), rejected, errors)
def test_echarts_html_css_color_sinks_accept_explicit_colors(self) -> None:
legitimate = ["red", "AliceBlue", "transparent", "#f00", "#abcd", "#112233", "#11223388",
"rgb(255, 0, 0)", "rgba(100%, 0%, 50%, .8)", "hsl(120, 50%, 50%)", "hsla(120, 50%, 50%, 0.5)"]
malicious = ['red;"><img src=/missing onerror=evil()>', "red;background-image:url(https://evil.example/a.png)",
["red;background-image:url(https://evil.example/a.png)"], "var(--untrusted)", "not-a-color"]
sinks = [lambda color: {"tooltip": {"backgroundColor": color}},
lambda color: {"series": [{"tooltip": {"borderColor": color}}]},
lambda color: {"tooltip": {"shadowColor": color, "textStyle": {"color": color, "textShadowColor": color}}},
lambda color: {"toolbox": {"feature": {"dataView": {"buttonColor": color, "buttonTextColor": color}}}}]
for sink in sinks:
for color, rejected in [(color, False) for color in legitimate] + [(color, True) for color in malicious]:
with self.subTest(payload=sink(color)):
parser = parse('<script type="application/json" data-td-echarts-options>' +
json.dumps(sink(color)).replace("<", "\\u003c") + '</script>')
self.assertEqual(bool(VALIDATOR.document_security_errors(parser, "index.html", BASE)), rejected)
for stop, rejected in [("#f00", False), ('red;"><img src=/missing onerror=evil()>', True)]:
parser = parse('<script type="application/json" data-td-echarts-options>' + json.dumps({
"tooltip": {"borderColor": {"type": "linear", "colorStops": [{"offset": 0, "color": stop}]}}}) + '</script>')
self.assertEqual(bool(VALIDATOR.document_security_errors(parser, "index.html", BASE)), rejected)
def test_echarts_marker_color_sources_accept_palettes_gradients_and_patterns(self) -> None:
gradient = {"type": "linear", "colorStops": [{"offset": 0, "color": "#f00"}, {"offset": 1, "color": "blue"}]}
pattern = {"image": "/local.png", "repeat": "repeat"}
safe_colors = ["#f00", "auto", "inherit", ["red", "#123456"], gradient, pattern]
bad_colors = ["red;background:url(https://evil.example/a.png)", "$fn:uncheckedColor",
["red", "blue;background:url(https://evil.example/a.png)"],
{"type": "linear", "colorStops": [{"offset": 0, "color": "red;background:url(https://evil.example/a.png)"}]}]
sinks = [lambda color: {"color": color}, lambda color: {"colorLayer": [color]},
lambda color: {"series": [{"color": color}]},
lambda color: {"series": [{"itemStyle": {"color": color}}]},
lambda color: {"series": [{"data": [{"value": 1, "itemStyle": {"color": color}}]}]},
lambda color: {"series": [{"lineStyle": {"color": color}}]},
lambda color: {"visualMap": {"inRange": {"color": color}, "outOfRange": {"color": color}}}]
for sink in sinks:
for color, rejected in [(color, False) for color in safe_colors] + [(color, True) for color in bad_colors]:
with self.subTest(option=sink(color)):
parser = parse('<script type="application/json" data-td-echarts-options>' + json.dumps(sink(color)) + '</script>')
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE) + \
VALIDATOR.document_url_shape_errors(parser, "index.html")
self.assertEqual(bool(errors), rejected, errors)
# The same fields in a dataset row are data, without a model style.
parser = parse('<script type="application/json" data-td-echarts-options>' + json.dumps({
"dataset": {"source": [{"color": bad_colors[0], "itemStyle": {"color": bad_colors[0]}}]}}) + '</script>')
self.assertEqual(VALIDATOR.document_security_errors(parser, "index.html", BASE), [])
def test_echarts_tooltip_css_numbers_and_font_options_use_declared_types(self) -> None:
safe = {"shadowBlur": 4, "shadowOffsetX": -2, "shadowOffsetY": 0, "borderWidth": 1, "borderRadius": [4, 6],
"padding": [4, 8], "textStyle": {"fontSize": 14, "lineHeight": 20, "textShadowBlur": 2,
"textShadowOffsetX": 0, "textShadowOffsetY": 1, "fontFamily": "Arial, 'Noto Sans CJK SC', sans-serif",
"fontStyle": "italic", "fontWeight": 600, "decoration": "underline", "align": "left"}}
cases = [(safe, False)]
for key in ("shadowBlur", "shadowOffsetX", "shadowOffsetY", "borderWidth", "borderRadius", "padding"):
cases.append(({key: "1px;background:url(https://evil.example/a.png)"}, True))
for key in ("fontSize", "lineHeight", "textShadowBlur", "textShadowOffsetX", "textShadowOffsetY",
"fontFamily", "fontStyle", "fontWeight", "decoration", "align"):
cases.append(({"textStyle": {key: "1px;background:url(https://evil.example/a.png)"}}, True))
cases.extend([({"shadowBlur": float("inf")}, True), ({"textStyle": {"fontSize": float("nan")}}, True),
({"textStyle": {"fontFamily": '"思源黑体", sans-serif', "fontWeight": "bold"}}, False),
({"textStyle": {"fontWeight": "600"}}, False), ({"shadowBlur": 10 ** 400}, True)])
for tooltip, rejected in cases:
with self.subTest(tooltip=tooltip):
parser = parse('<script type="application/json" data-td-echarts-options>' +
json.dumps({"tooltip": tooltip}) + '</script>')
self.assertEqual(bool(VALIDATOR.document_security_errors(parser, "index.html", BASE)), rejected)
def test_echarts_array_symbols_and_pattern_images_are_active_resources(self) -> None:
shapes = [lambda image: {"series": [{"markLine": {"symbol": ["image://" + image, "none"]}}]},
lambda image: {"visualMap": {"inRange": {"symbol": ["circle", "image://" + image]}}},
lambda image: {"series": [{"itemStyle": {"color": {"image": image, "repeat": "repeat"}}}]},
lambda image: {"graphic": {"style": {"fill": {"image": image}}}}]
for shape in shapes:
for image, rejected in [("https://evil.example/image.png", True), ("//evil.example/image.png", True),
("/local.png", False), ("https://hugegraph-oink.staged.apache.org/local.png", False)]:
with self.subTest(payload=shape(image)):
parser = parse('<script type="application/json" data-td-echarts-options>' + json.dumps(shape(image)) + '</script>')
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE) + \
VALIDATOR.document_url_shape_errors(parser, "index.html")
self.assertEqual(bool(errors), rejected, errors)
@unittest.skipUnless(shutil.which(HUGO_BIN), "Hugo is required to render the pinned component fixture")
def test_tagged_hugo_component_output_and_security_cli(self) -> None:
repo = VALIDATOR_PATH.parents[1]
with tempfile.TemporaryDirectory(prefix="oink-component-security-") as temporary:
root = pathlib.Path(temporary)
for name in ("go.mod", "go.sum"):
shutil.copyfile(repo / name, root / name)
(root / "hugo.yaml").write_text(
"baseURL: https://hugegraph-oink.staged.apache.org/\nmodule:\n imports:\n - path: github.com/pgsty/oink\n")
(root / "layouts").mkdir()
(root / "layouts/single.html").write_text("<article>{{ .Content }}</article>")
(root / "content").mkdir()
(root / "content/fixture.md").write_text(
'---\ntitle: Fixture\n---\n'
'{{< asciinema file="/recording.cast" markers="10:Boot,25:Configure" >}}\n\n'
'```echarts\n{"dataset":{"source":[{"url":"https://example.org/inert","value":42}]},'
'"series":[{"type":"bar"}]}\n```\n')
result = subprocess.run([shutil.which(HUGO_BIN), "--source", str(root), "--destination", str(root / "public")],
text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
html = (root / "public/fixture/index.html").read_text()
self.assertIn('"markers":[10,"Boot",25,"Configure"]', html)
parser = parse(html)
self.assertEqual(VALIDATOR.document_security_errors(parser, "fixture/index.html", BASE), [])
# Check the rendered component page in isolation; the fixture does
# not include HugeGraph's SEO/CSS customizations for theme defaults.
artifact = root / "artifact"
artifact.mkdir()
(artifact / "index.html").write_text(html)
result = subprocess.run([sys.executable, str(VALIDATOR_PATH), str(artifact), BASE.geturl(), "--security-only"],
text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
# The actual native wrappers plus an alternate config carrier must
# fail the same CLI, not just a manually constructed parser test.
malicious = html.replace('<script type="application/json" data-td-asciinema-config>',
'<pre data-td-asciinema-config>', 1)
malicious = malicious.replace('</script>', '</pre>', 1)
(artifact / "index.html").write_text(malicious)
result = subprocess.run([sys.executable, str(VALIDATOR_PATH), str(artifact), BASE.geturl(), "--security-only"],
text=True, capture_output=True)
self.assertNotEqual(result.returncode, 0)
self.assertIn("noncanonical OINK component", result.stdout)
(root / "content/fixture.md").write_text(
'---\ntitle: Unsafe fixture\n---\n```echarts\n' + json.dumps({
"tooltip": {"extraCssText": "background:url(https://evil.example/a.png)"},
"toolbox": {"feature": {"dataView": {"lang": ['<img src="/ok.png" onerror="evil()">']}}},
"title": {"link": "javascript:evil()"},
}) + '\n```\n')
html_option = {"toolbox": {"feature": {"dataView": {"lang": ['<img src="/missing" onerror="evil()">']}}}}
native_counterexamples = [
({"series": [{"tooltip": '<img src="/missing" onerror="evil()">'}]}, "onerror"),
({"series": [{"data": [{"value": 5, "tooltip": '<img src="/missing" onerror="evil()">'}]}]}, "onerror"),
({"textStyle": {"fontFamily": "serif;background:url(https://evil.example/font.png)"}}, "invalid ECharts tooltip CSS font"),
({"visualMap": {"pieces": [{"color": "red;background:url(https://evil.example/piece.png)"}]}}, "invalid ECharts tooltip marker colour"),
({"tooltip": {"defaultBorderColor": "red;background:url(https://evil.example/border.png)"}}, "invalid ECharts CSS colour"),
({"color": ["red;background:url(https://evil.example/palette.png)"]}, "invalid ECharts tooltip marker colour"),
({"series": [{"itemStyle": {"color": "red;background:url(https://evil.example/itemstyle.png)"}}]},
"invalid ECharts tooltip marker colour"),
({"baseOption": html_option}, "onerror"),
({"baseOption": {}, "options": [html_option]}, "onerror"),
({"media": [{"query": {"maxWidth": 600}, "option": html_option}]}, "onerror"),
({"tooltip": {"backgroundColor": 'red;"><img src=/missing onerror=evil()>'}}, "invalid ECharts CSS colour"),
({"toolbox": {"feature": {"dataView": {"buttonColor": "red;background:url(https://evil.example/a.png)"}}}},
"invalid ECharts CSS colour"),
({"series": [{"markLine": {"symbol": ["image://https://evil.example/a.png", "none"]}}]}, "external active resource"),
({"visualMap": {"inRange": {"symbol": ["circle", "image://https://evil.example/a.png"]}}}, "external active resource"),
({"series": [{"itemStyle": {"color": {"image": "https://evil.example/a.png"}}}]}, "external active resource"),
]
for index, (option, _) in enumerate(native_counterexamples):
(root / f"content/unsafe-{index}.md").write_text(
'---\ntitle: Unsafe fixture\n---\n```echarts\n' + json.dumps(option) + '\n```\n')
result = subprocess.run([shutil.which(HUGO_BIN), "--source", str(root), "--destination", str(root / "public")],
text=True, capture_output=True)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
for index, (_, message) in enumerate(native_counterexamples):
with self.subTest(native_counterexample=index):
shutil.copyfile(root / f"public/unsafe-{index}/index.html", artifact / "index.html")
result = subprocess.run([sys.executable, str(VALIDATOR_PATH), str(artifact), BASE.geturl(), "--security-only"],
text=True, capture_output=True)
self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn(message, result.stdout)
shutil.copyfile(root / "public/fixture/index.html", artifact / "index.html")
result = subprocess.run([sys.executable, str(VALIDATOR_PATH), str(artifact), BASE.geturl(), "--security-only"],
text=True, capture_output=True)
self.assertNotEqual(result.returncode, 0)
# URL-shape errors take priority in this CLI; independently verify
# all three runtime sinks on the very same rendered fixture.
parser = parse((artifact / "index.html").read_text())
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE) + \
VALIDATOR.document_url_shape_errors(parser, "index.html")
for message in ("external CSS", "onerror", "forbidden URL scheme"):
self.assertIn(message, "\n".join(errors))
def test_asciinema_payload_rejects_player_apis_outside_shortcode_schema(self) -> None:
payload = {"src": "/recording.cast", "theme": "auto", "options": {"autoPlay": False}}
variants = [dict(payload, audioUrl="https://evil.example/audio.mp3"),
dict(payload, options={"audioUrl": "https://evil.example/audio.mp3"}),
dict(payload, options={"driver": "benchmark"}),
dict(payload, options={"poster": "https://evil.example/poster.png"}),
dict(payload, options={"markers": [{"src": "https://evil.example/x"}]}),
dict(payload, options={"markers": [[1.0, "start"]]}),
dict(payload, options={"markers": [1.0]}),
dict(payload, options={"speed": {"url": "https://evil.example/x"}})]
for variant in variants:
with self.subTest(variant=variant):
parser = parse('<article><script type="application/json" data-td-asciinema-config>' +
json.dumps(variant) + "</script></article>")
self.assertTrue(VALIDATOR.document_security_errors(parser, "index.html", BASE))
for poster in ("npt:0:10", "data:text/plain,hello"):
parser = parse('<article><script type="application/json" data-td-asciinema-config>' +
json.dumps(dict(payload, options={"poster": poster, "markers": [1.0, "start"]})) + "</script></article>")
self.assertEqual(VALIDATOR.document_security_errors(parser, "index.html", BASE), [])
def test_http_resources_are_distinct_from_ordinary_links(self) -> None:
parser = parse(
'<a href="http://example.com/docs">docs</a>'
'<img src="http://example.com/image.png" '
'srcset="/small.png 1x, http://example.com/large.png 2x">'
'<link rel="stylesheet" href="http://example.com/site.css">'
)
insecure = [
url
for _tag, _attribute, url in parser.resources
if urllib.parse.urlsplit(url).scheme == "http"
]
self.assertEqual(
insecure,
[
"http://example.com/image.png",
"http://example.com/large.png",
"http://example.com/site.css",
],
)
def test_authorityless_and_ambiguous_http_links_are_rejected(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
docs = root / "docs/introduction"
docs.mkdir(parents=True)
(docs / "index.html").write_text("target", encoding="utf-8")
(root / "index.html").write_text(
(
'<a href="https:///docs/introduction/">triple</a>'
'<a href="https:////docs/introduction/">quad</a>'
'<a href="http:docs/introduction/">opaque</a>'
'<a href="/docs/introduction/\t">control</a>'
'<a href="/docs\\introduction/">backslash</a>'
'<img srcset="https:///docs/introduction/ 1x">'
'<object data="https:///docs/introduction/"></object>'
'<video poster="https:///docs/introduction/"></video>'
'<style>.hero{background:url(https:///docs/introduction/)}</style>'
'<a href="javascript:alert(1)">active link</a>'
'<script src="javascript:alert(1)"></script>'
'<iframe src="javascript:alert(1)"></iframe>'
'<object data="javascript:alert(1)"></object>'
'<a href="mailto:dev@example.com">allowed contact</a>'
),
encoding="utf-8",
)
(root / "boundary.html").write_text(
(
'<form action="https://evil.example/collect">'
'<button formaction="https://evil.example/button">go</button>'
'<input formaction="https://evil.example/input">'
"</form>"
'<a href="/docs/" ping="https://evil.example/ping">ping</a>'
'<area href="/docs/" ping="https://evil.example/ping">'
'<base href="https://evil.example/">'
),
encoding="utf-8",
)
(root / "site.css").write_text(
".hero{background:url(https:///docs/introduction/)}",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("HTTP(S) URL has no authority", result.stdout)
self.assertGreaterEqual(
result.stdout.count("HTTP(S) URL has no authority"),
6,
result.stdout,
)
self.assertIn("unsafe whitespace/control URL", result.stdout)
self.assertIn("unsafe backslash URL", result.stdout)
self.assertIn("forbidden URL scheme", result.stdout)
self.assertIn("site.css", result.stdout)
def test_security_only_rejects_unreviewed_request_attributes(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
(root / "index.html").write_text(
(
'<form action="javascript:alert(1)">'
'<button formaction="javascript:alert(1)">go</button>'
'<input formaction="https://evil.example/submit">'
"</form>"
'<a href="/docs/" ping="https://evil.example/ping">ping</a>'
'<area href="/docs/" ping="https://evil.example/ping">'
'<base href="https://evil.example/">'
'<svg><use href="javascript:alert(1)"></use></svg>'
),
encoding="utf-8",
)
(root / "external-boundary.html").write_text(
(
'<form action="https://evil.example/collect">'
'<button formaction="https://evil.example/button">go</button>'
'<input formaction="https://evil.example/input">'
"</form>"
'<a href="/docs/" ping="https://evil.example/ping">ping</a>'
'<area href="/docs/" ping="https://evil.example/ping">'
'<base href="https://evil.example/">'
'<link rel="prefetch" href="https://evil.example/payload">'
'<link rel="prerender" href="https://evil.example/page">'
'<link rel="preconnect" href="https://evil.example/">'
'<link rel="dns-prefetch" href="https://evil.example/">'
'<link rel="preload" as="image" '
'imagesrcset="https://evil.example/preload.png 1x">'
'<svg><use xlink:href="https://evil.example/icon.svg"></use>'
'<image xlink:href="https://evil.example/image.svg"></image></svg>'
'<img src="/local.png" '
'attributionsrc="https://evil.example/attribute">'
),
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("forbidden ping attribute", result.stdout)
self.assertIn("forbidden base[href]", result.stdout)
self.assertIn("forbidden URL scheme in form[action]", result.stdout)
self.assertIn("forbidden URL scheme in button[formaction]", result.stdout)
self.assertIn("external active resource", result.stdout)
self.assertIn(
"external active resource is forbidden <link> href: "
"https://evil.example/payload",
result.stdout,
)
self.assertIn(
"external active resource is forbidden <link> href: "
"https://evil.example/page",
result.stdout,
)
self.assertIn(
"external active resource is forbidden <use> xlink:href",
result.stdout,
)
self.assertIn(
"external active resource is forbidden <link> imagesrcset",
result.stdout,
)
self.assertIn("forbidden attributionsrc attribute", result.stdout)
self.assertIn("forbidden URL scheme in use[href]", result.stdout)
def test_runtime_url_attributes_enter_security_and_target_validation(self) -> None:
fragment = (
'<main data-td-index-src="/missing-index.json" '
'data-td-url="/missing-runtime/" '
'data-td-action-url="/missing-action/" '
'data-td-image-zoom="https://images.example.com/zoom.png"></main>'
)
parser = parse(fragment)
for attribute, url in (
("data-td-index-src", "/missing-index.json"),
("data-td-url", "/missing-runtime/"),
("data-td-action-url", "/missing-action/"),
("data-td-image-zoom", "https://images.example.com/zoom.png"),
):
self.assertIn((attribute, url), parser.urls)
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE)
self.assertIn(
"index.html: image URL is outside ASF CSP <main> "
"data-td-image-zoom: https://images.example.com/zoom.png",
errors,
)
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
(root / "index.html").write_text(fragment, encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1)
self.assertIn(
"broken internal data-td-index-src /missing-index.json",
result.stdout,
)
self.assertIn(
"broken internal data-td-url /missing-runtime/",
result.stdout,
)
self.assertIn(
"broken internal data-td-action-url /missing-action/",
result.stdout,
)
def test_security_only_rejects_data_resource_schemes(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
data_image = "data:image/png;base64,AAAA"
(root / "index.html").write_text(
(
f'<img src="{data_image}" srcset="{data_image} 1x">'
f'<picture><source srcset="{data_image} 1x"></picture>'
f'<video poster="{data_image}"></video>'
f'<style>.hero{{background:url({data_image})}}</style>'
),
encoding="utf-8",
)
(root / "site.css").write_text(
f".hero{{background:url({data_image})}}",
encoding="utf-8",
)
passive = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(passive.returncode, 1)
self.assertIn("forbidden URL scheme in img[src]", passive.stdout)
self.assertIn("forbidden URL scheme in inline CSS", passive.stdout)
self.assertIn("forbidden URL scheme in CSS resource", passive.stdout)
(root / "active.html").write_text(
f'<script src="{data_image}"></script>',
encoding="utf-8",
)
rejected = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(rejected.returncode, 1)
self.assertIn("forbidden URL scheme in script[src]", rejected.stdout)
def test_css_http_resources_are_detected(self) -> None:
parser = parse(
'<style>@import "http://example.com/base.css"; '
".hero { background: url(http://example.com/hero.png) }</style>"
"<p style=\"background-image:url('http://example.com/card.png')\">x</p>"
)
self.assertEqual(
parser.inline_css_http_resources,
[
"http://example.com/base.css",
"http://example.com/hero.png",
"http://example.com/card.png",
],
)
def test_css_external_resources_are_detected(self) -> None:
source = (
'@import "https://evil.example/base.css";'
".hero { background: url(//evil.example/hero.png) }"
".self { background: url(/img/self.png) }"
)
self.assertEqual(
VALIDATOR.css_external_resources(source, BASE),
[
"https://evil.example/base.css",
"//evil.example/hero.png",
],
)
def test_css_tokenizer_covers_escaped_and_image_set_resources(self) -> None:
source = (
'@\\69mport "https://evil.example/import with space.css";'
".quoted { background: url('https://evil.example/image with space.png') }"
".escaped { background: u\\72l(h\\74tps://evil.example/escaped.png) }"
".set { background-image: image-set("
'"https://evil.example/one.png" 1x, '
"url(https://evil.example/two.png) 2x) }"
".webkit { background-image: -webkit-image-set("
'"https://evil.example/three.png" 1x) }'
)
self.assertEqual(
VALIDATOR.css_resource_urls(source),
[
"https://evil.example/import with space.css",
"https://evil.example/image with space.png",
"https://evil.example/escaped.png",
"https://evil.example/one.png",
"https://evil.example/two.png",
"https://evil.example/three.png",
],
)
def test_css_request_surfaces_share_the_same_security_policy(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
escaped = "h\\74tps://evil.example/request.png"
(root / "index.html").write_text(
(
f'<div style="background:image-set(&quot;{escaped}&quot; 1x)">'
"</div>"
f"<style>.hero{{background:url('{escaped}')}}</style>"
),
encoding="utf-8",
)
(root / "site.css").write_text(
f".hero{{background:-webkit-image-set(\"{escaped}\" 1x)}}",
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertGreaterEqual(
result.stdout.count("external CSS resource is forbidden"),
3,
result.stdout,
)
def test_css_internal_resources_resolve_from_stylesheet_directory(self) -> None:
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
(root / "index.html").write_text("<main>safe</main>", encoding="utf-8")
(root / "scss").mkdir()
(root / "img").mkdir()
(root / "img/present.svg").write_text("<svg/>", encoding="utf-8")
(root / "scss/site.css").write_text(
(
'.present{background:url("../img/present.svg")}'
'.scoped{background:url("https://hugegraph.apache.org/'
'versions/1.7/img/present.svg")}'
'.missing{background:url("../img/missing.svg")}'
'@import "./missing.css";'
'.escape{background:url("../../escape.svg")}'
'.fragment{filter:url("#local-filter")}'
),
encoding="utf-8",
)
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/versions/1.7/",
"--security-only",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertNotIn("present.svg", result.stdout)
self.assertIn(
"broken internal CSS resource ../img/missing.svg -> img/missing.svg",
result.stdout,
)
self.assertIn(
"broken internal CSS resource ./missing.css -> scss/missing.css",
result.stdout,
)
self.assertIn(
"unsafe internal CSS resource ../../escape.svg: "
"escapes output directory",
result.stdout,
)
self.assertNotIn("local-filter", result.stdout)
def test_parser_covers_legacy_and_svg_request_surfaces(self) -> None:
parser = parse(
'<iframe srcdoc="<script>run()</script>"></iframe>'
'<frame src="https://evil.example/frame">'
'<body background="https://images.example.com/body.png">'
'<table background="/table.png"><tr>'
'<th background="/head.png">h</th>'
'<td background="/cell.png">c</td></tr></table>'
'<svg>'
'<a xlink:href="https://example.com/docs">docs</a>'
'<image xlink:href="https://www.apache.org/image.svg"></image>'
'<feImage href="https://www.apache.org/filter.svg"></feImage>'
'<mpath xlink:href="https://evil.example/path.svg#p"></mpath>'
'<pattern href="https://evil.example/pattern.svg#p"></pattern>'
'<path fill="url(https://evil.example/paint.svg#gradient)"></path>'
"</svg>"
)
self.assertIn("forbidden iframe[srcdoc]", parser.authored_violations)
self.assertIn(
("a", "xlink:href", "https://example.com/docs"),
parser.navigation_urls,
)
self.assertIn(
("mpath", "xlink:href", "https://evil.example/path.svg#p"),
parser.resources,
)
self.assertIn(
("pattern", "href", "https://evil.example/pattern.svg#p"),
parser.resources,
)
self.assertIn(
("image", "xlink:href", "https://www.apache.org/image.svg"),
parser.image_urls,
)
self.assertIn(
("feimage", "href", "https://www.apache.org/filter.svg"),
parser.image_urls,
)
self.assertIn(
"https://evil.example/paint.svg#gradient",
[
url
for source in parser.inline_css_sources
for url in VALIDATOR.css_resource_urls(source)
],
)
self.assertIn(
"external active resource is forbidden <frame> src",
"\n".join(
VALIDATOR.document_security_errors(parser, "index.html", BASE)
),
)
def test_media_sources_are_not_treated_as_csp_images(self) -> None:
parser = parse(
'<picture><source srcset="https://www.apache.org/image.webp 1x">'
'<img src="https://www.apache.org/image.png"></picture>'
'<video><source src="https://www.apache.org/video.mp4" '
'srcset="https://www.apache.org/video-hd.mp4 2x"></video>'
'<audio><source src="https://www.apache.org/audio.mp3"></audio>'
'<video></audio>'
'<source src="https://www.apache.org/mismatched.mp4"></video>'
)
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE)
self.assertEqual(
sum("external active resource" in error for error in errors),
4,
errors,
)
self.assertFalse(
any("image.webp" in error or "image.png" in error for error in errors),
errors,
)
def test_authored_content_markers_survive_premature_container_closures(
self,
) -> None:
parser = parse(
'<main><article><template data-hg-authored-content="start"></template>'
'</article></main><script src="/escaped.js"></script>'
'<template data-hg-authored-content="end"></template>'
)
self.assertIn("authored <script>", parser.authored_violations)
spoofed = parse(
'<template data-hg-authored-content="start"></template>'
'<template data-hg-authored-content="end"></template>'
'<script src="/escaped.js"></script>'
'<template data-hg-authored-content="end"></template>'
)
self.assertIn(
"unexpected authored-content end marker",
spoofed.authored_violations,
)
self.assertTrue(
VALIDATOR.document_security_errors(spoofed, "index.html", BASE)
)
sequential = parse(
'<template data-hg-authored-content="start"></template>'
"<p>one</p>"
'<template data-hg-authored-content="end"></template>'
'<template data-hg-authored-content="start"></template>'
"<p>two</p>"
'<template data-hg-authored-content="end"></template>'
)
self.assertEqual(
VALIDATOR.document_security_errors(sequential, "print.html", BASE),
[],
)
def test_link_rel_is_fail_closed_for_request_capable_and_unknown_values(self) -> None:
parser = parse(
'<link rel="canonical" href="https://hugegraph-oink.staged.apache.org/">'
'<link rel="alternate" hreflang="zh-CN" '
'href="https://hugegraph-oink.staged.apache.org/cn/">'
'<link rel="mask-icon" href="https://evil.example/mask.svg">'
'<link rel="apple-touch-startup-image" '
'href="https://evil.example/start.png">'
'<link rel="future-browser-fetch" href="https://evil.example/future">'
)
errors = VALIDATOR.document_security_errors(parser, "index.html", BASE)
self.assertEqual(len(errors), 3, errors)
self.assertTrue(all("external active resource" in error for error in errors))
def test_srcset_and_imagesrcset_candidates_are_internal_targets(self) -> None:
fragment = (
'<img srcset="/present.png 1x, /missing.png 2x">'
'<link rel="preload" as="image" '
'imagesrcset="/present-link.png 1x, /missing-link.png 2x">'
)
parser = parse(fragment)
self.assertIn(("srcset", "/missing.png"), parser.urls)
self.assertIn(("imagesrcset", "/missing-link.png"), parser.urls)
with tempfile.TemporaryDirectory() as temp_name:
root = pathlib.Path(temp_name)
(root / "index.html").write_text(fragment, encoding="utf-8")
(root / "present.png").write_bytes(b"present")
(root / "present-link.png").write_bytes(b"present")
result = subprocess.run(
[
sys.executable,
str(VALIDATOR_PATH),
str(root),
"https://hugegraph.apache.org/",
],
check=False,
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 1)
self.assertIn(
"broken internal srcset /missing.png -> missing.png",
result.stdout,
)
self.assertIn(
"broken internal imagesrcset /missing-link.png -> missing-link.png",
result.stdout,
)
def test_asf_csp_image_sources_are_allowed(self) -> None:
allowed = [
"/img/local.svg",
"https://hugegraph-oink.staged.apache.org/img/self.svg",
"https://apache.org/img/foundation.svg",
"https://www.apache.org/img/logo.svg",
"https://community.apache.org/img/community.svg",
"https://www.apachecon.com/img/event.svg",
"https://www.communityovercode.org/img/event.svg",
"https://c2.scarf.sh/a.png",
]
for url in allowed:
with self.subTest(url=url):
self.assertTrue(VALIDATOR.image_url_allowed_by_asf_csp(url, BASE))
def test_non_csp_external_image_sources_are_rejected(self) -> None:
rejected = [
"http://www.apache.org/img/logo.svg",
"https://images.example.com/hero.png",
"https://apache.org.evil.example/hero.png",
"https://www.apache.org:8443/img/logo.svg",
"https://scarf.sh/a.png",
"//images.example.com/hero.png",
]
for url in rejected:
with self.subTest(url=url):
self.assertFalse(VALIDATOR.image_url_allowed_by_asf_csp(url, BASE))
def test_external_active_resources_are_rejected(self) -> None:
parser = parse(
'<script src="https://evil.example/app.js"></script>'
'<link rel="stylesheet" href="https://evil.example/app.css">'
'<img src="https://www.apache.org/img/logo.svg">'
)
self.assertEqual(
VALIDATOR.document_security_errors(parser, "index.html", BASE),
[
"index.html: external active resource is forbidden <script> src: "
"https://evil.example/app.js",
"index.html: external active resource is forbidden <link> href: "
"https://evil.example/app.css",
],
)
def test_srcset_candidates_are_all_checked(self) -> None:
parser = parse(
'<picture><source srcset="/small.webp 1x, '
'https://images.example.com/large.webp 2x">'
'<img src="/fallback.png" srcset="data:image/png;base64,AAAA 1x, '
'https://www.apache.org/img/large.png 2x"></picture>'
)
self.assertEqual(
parser.image_urls,
[
("source", "srcset", "/small.webp"),
("source", "srcset", "https://images.example.com/large.webp"),
("img", "src", "/fallback.png"),
("img", "srcset", "data:image/png;base64,AAAA"),
("img", "srcset", "https://www.apache.org/img/large.png"),
],
)
def test_srcset_candidates_without_spaces_are_all_checked(self) -> None:
parser = parse(
'<source srcset="https://www.apache.org/a.png,http://evil.example/b.png">'
)
self.assertEqual(
parser.image_urls,
[
("source", "srcset", "https://www.apache.org/a.png"),
("source", "srcset", "http://evil.example/b.png"),
],
)
self.assertEqual(
VALIDATOR.document_security_errors(parser, "index.html", BASE),
[
"index.html: mixed-content <source> srcset: http://evil.example/b.png",
],
)
def test_security_decision_accepts_shell_and_asf_images(self) -> None:
parser = parse(
'<script src="/shell.js"></script><main><img '
'src="https://www.apache.org/img/logo.svg"></main>'
)
self.assertEqual(
VALIDATOR.document_security_errors(parser, "index.html", BASE), []
)
def test_security_decision_reports_each_failure_class(self) -> None:
parser = parse(
'<link rel="stylesheet" href="http://example.com/site.css">'
'<main onload="run()" style="background:url(http://example.com/bg.png)">'
'<iframe src="/frame"></iframe>'
'<source srcset="/small.png 1x, http://example.com/large.png 2x">'
'<img src="https://images.example.com/hero.png"></main>'
)
self.assertEqual(
VALIDATOR.document_security_errors(parser, "docs/index.html", BASE),
[
"docs/index.html: unsafe content markup: authored onload event "
"attribute on <main>",
"docs/index.html: unsafe content markup: authored <iframe>",
"docs/index.html: mixed-content CSS resource: "
"http://example.com/bg.png",
"docs/index.html: mixed-content <link> href: "
"http://example.com/site.css",
"docs/index.html: mixed-content <source> srcset: "
"http://example.com/large.png",
"docs/index.html: image URL is outside ASF CSP <img> src: "
"https://images.example.com/hero.png",
],
)
if __name__ == "__main__":
unittest.main()