diff --git a/COVERAGE.md b/COVERAGE.md index cac243a..ea10671 100644 --- a/COVERAGE.md +++ b/COVERAGE.md @@ -67,6 +67,56 @@ project-authored code, with the inherited modules without tests omitted in `pyproject.toml` until their tests land. Both thresholds are only ever raised. The sections that follow record the state before the merges. +## Branch fix/no-third-party-script: the first boot page (2026-09-28) + +`firstboot.d/29tagid` and `lib/tagid.sh` stop putting a third party script +on the page the appliance serves before anybody has logged in +(Keel-Linux/inithooks#13). `tests/test-tagid.bats` grows from 14 to 27 +tests. Measured with kcov 43 and bats 1.11, 124 bats over the six files: + +| File | Before | After | +| --- | --- | --- | +| `firstboot.d/29tagid` | 19/19 | 17/17, 100 percent | +| `lib/tagid.sh` | 8/8 | 2/2, 100 percent | + +Both files shrank because four functions went away with the scripts they +built. Total 99.53 (was 99.40). The lowest file is still +`lib/init-fence.sh` at 98.44, so the shell gate stays at 98. Python is +untouched: 187 passed, 3 skipped. + +**What is asserted, and what is not.** The verdict is taken from the +rendered page, not from the source of the hook: the page is parsed for +every host a ` - -EOT -} diff --git a/tests/remote_loads.py b/tests/remote_loads.py new file mode 100755 index 0000000..69a2abb --- /dev/null +++ b/tests/remote_loads.py @@ -0,0 +1,133 @@ +#!/usr/bin/python3 +# Copyright (c) 2026 TurnKey GNU/Linux +"""Print every URL on another host that an HTML page fetches on its own + + remote_loads.py PAGE + +Exit status: 0 the page fetches nothing from another host, 1 it does (each +URL printed on a line of its own), 2 the page could not be read. + +"On its own" is what a browser does while it renders the page, before +anybody clicks: scripts, stylesheets and every other link element, images +and srcsets, frames, objects, media and their posters and tracks, a base +URL, a meta refresh, and url() and @import in style attributes and style +elements. A link (, ) and a form action are somewhere the operator +may choose to go, and are not reported. Inline script is not parsed. + +"Another host" is any URL with a scheme other than data, or with no scheme +and two leading slashes. A browser strips tabs and newlines from a URL and +reads a backslash as a slash, and so does this, so neither is a way past +it. When in doubt, a URL is reported. + +It is a test instrument, not a defence: tests/test-tagid.bats asks it about +every page the fence serves. It is built on Python's HTMLParser, which does +not parse malformed markup the way a browser does (an abruptly closed +comment such as '' hides what follows from it, and not from a +browser), so it says what a well formed page loads, and the hook does not +rely on it: firstboot.d/29tagid serves the packaged page and nothing else. +""" + +import re +import sys +from html.parser import HTMLParser + +# attributes that make the element fetch, whatever the element is +FETCHING = {"src", "srcset", "poster", "data", "background", "lowsrc"} +# href fetches on every element but these, which navigate on a click +NAVIGATING = {"a", "area"} +# a scheme that does not reach a host +LOCAL_SCHEMES = {"data", "about", "blob", "javascript"} + +CSS_URL = re.compile(r"""url\(\s*(['"]?)(.*?)\1\s*\)""", re.I | re.S) +CSS_IMPORT = re.compile(r"""@import\s+(['"])(.*?)\1""", re.I | re.S) +REFRESH_URL = re.compile(r"""^\s*\d*\s*[;,]?\s*url\s*=\s*['"]?([^'"]*)""", + re.I) +SCHEME = re.compile(r"^([a-z][a-z0-9+.-]*):", re.I) + + +def is_remote(url: str) -> bool: + """Whether a browser would reach another host for URL""" + url = re.sub(r"[\t\n\r]", "", url).strip().replace("\\", "/") + if url.startswith("//"): + return True + match = SCHEME.match(url) + return bool(match) and match.group(1).lower() not in LOCAL_SCHEMES + + +def srcset_urls(value: str) -> list: + """The URLs of a srcset: each candidate is a URL and a descriptor""" + return [part.split()[0] for part in value.split(",") if part.split()] + + +def css_urls(text: str) -> list: + """The URLs a stylesheet or a style attribute fetches""" + return ([m.group(2) for m in CSS_URL.finditer(text)] + + [m.group(2) for m in CSS_IMPORT.finditer(text)]) + + +class _Finder(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs=True) + self.found = [] + self._in_style = False + + def _check(self, urls): + self.found.extend(url for url in urls if is_remote(url)) + + def handle_starttag(self, tag, attrs): + tag = tag.lower() + values = {} + for name, value in attrs: + values[name.lower()] = value or "" + for name, value in values.items(): + if name in FETCHING: + self._check(srcset_urls(value) if name == "srcset" + else [value]) + elif name in ("href", "xlink:href") and tag not in NAVIGATING: + self._check([value]) + elif name == "style": + self._check(css_urls(value)) + if tag == "meta" and values.get("http-equiv", "").lower() == "refresh": + match = REFRESH_URL.match(values.get("content", "")) + if match: + self._check([match.group(1)]) + if tag == "style": + self._in_style = True + + handle_startendtag = handle_starttag + + def handle_endtag(self, tag): + if tag.lower() == "style": + self._in_style = False + + def handle_data(self, data): + if self._in_style: + self._check(css_urls(data)) + + +def remote_loads(page: str) -> list: + """Every URL on another host PAGE fetches on its own, in page order""" + finder = _Finder() + finder.feed(page) + finder.close() + return [url.strip() for url in finder.found] + + +def main(argv: list) -> int: + if len(argv) != 1: + print("usage: remote_loads.py PAGE", file=sys.stderr) + return 2 + try: + with open(argv[0], "rb") as fob: + page = fob.read().decode("utf-8", errors="replace") + except OSError as exc: + print(f"remote_loads.py: {exc}", file=sys.stderr) + return 2 + found = remote_loads(page) + for url in found: + print(url) + return 1 if found else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test-tagid.bats b/tests/test-tagid.bats index ef71524..7bb886c 100644 --- a/tests/test-tagid.bats +++ b/tests/test-tagid.bats @@ -1,10 +1,18 @@ #!/usr/bin/env bats -# Tests for lib/tagid.sh and firstboot.d/29tagid. +# Tests for lib/tagid.sh and firstboot.d/29tagid: the pages the appliance +# serves on first boot, before anybody has logged in. # -# The library functions are called directly; the hook is run against scratch -# copies of /etc/turnkey_version, /etc/apt/apt.conf.d/01turnkey, -# /etc/default/turnkey-init-fence and the fence htdocs, with systemctl -# replaced by a stub. +# The verdict that matters is taken from what is served, not from the +# source of the hook (docs/traps.md, "Asserting the configuration is not +# asserting the behaviour"), and by two detectors that do not share a +# parser: tests/remote_loads.py, which parses the page with Python's +# HTMLParser and knows every attribute and style construct that fetches, +# and remote_tokens below, which knows no HTML at all and reports any URL +# with a host in any tag that is not a link, in any quoting or none. A grep +# for one known hostname would pass the day somebody adds a different one. +# +# Refutations are written "run ! cmd", never a bare "! cmd": bash does not +# apply errexit to a negated command, so a bare one asserts nothing. bats_require_minimum_version 1.5.0 @@ -14,7 +22,8 @@ REPO=$BATS_TEST_DIRNAME/.. HOOK=$REPO/firstboot.d/29tagid TKL_VERSION=turnkey-core-19.0-trixie-amd64 APT_LINE='Acquire::http::User-Agent "TurnKey APT-HTTP/1.3 (turnkey-core-19.0-trixie-amd64)";' -SCRIPTS=' +# what an image built before Keel-Linux/inithooks#13 put at the end of the page +OLD_SCRIPTS=' ' setup() { @@ -26,10 +35,9 @@ exit 0' # a scratch /usr/lib/inithooks with the packaged htdocs and the library export INITHOOKS_PATH=$BATS_TEST_TMPDIR/inithooks - mkdir -p "$INITHOOKS_PATH/turnkey-init-fence/htdocs" + mkdir -p "$INITHOOKS_PATH/turnkey-init-fence" ln -s "$REPO/lib" "$INITHOOKS_PATH/lib" - cp "$REPO/turnkey-init-fence/htdocs/index.html" \ - "$INITHOOKS_PATH/turnkey-init-fence/htdocs/index.html" + cp -R "$REPO/turnkey-init-fence/htdocs" "$INITHOOKS_PATH/turnkey-init-fence/" export HTDOCS=$BATS_TEST_TMPDIR/var/turnkey-init-fence/htdocs export INITFENCE_DEFAULT=$BATS_TEST_TMPDIR/default-turnkey-init-fence @@ -39,87 +47,281 @@ exit 0' export APT_CONF_TURNKEY=$BATS_TEST_TMPDIR/01turnkey echo "$TKL_VERSION" > "$TURNKEY_VERSION_FILE" echo "$APT_LINE" > "$APT_CONF_TURNKEY" -} -# ---------------------------------------------------------------- library + PACKAGED=$REPO/turnkey-init-fence/htdocs/index.html + RENDERED=$HTDOCS/index.html + # the page the hook is expected to serve: the packaged one, named + EXPECTED=$BATS_TEST_TMPDIR/expected.html + sed 's|@APP_NAME@|core|' "$PACKAGED" > "$EXPECTED" +} -@test "app_name strips the turnkey prefix and the version" { - [ "$(tagid_app_name turnkey-core-19.0-trixie-amd64)" = core ] - [ "$(tagid_app_name turnkey-wordpress-19.0-trixie-amd64)" = wordpress ] - [ "$(tagid_app_name turnkey-gitea-19.1-trixie-arm64)" = gitea ] +# remote_loads FILE +# What tests/remote_loads.py says FILE fetches from another host. +remote_loads() { + python3 "$BATS_TEST_DIRNAME/remote_loads.py" "$1" } -@test "version keeps the last three dash separated fields" { - [ "$(tagid_version turnkey-core-19.0-trixie-amd64)" = 19.0-trixie-amd64 ] - [ "$(tagid_version turnkey-gitea-19.1-trixie-arm64)" = 19.1-trixie-arm64 ] +# remote_tokens FILE +# Every URL with a host in a tag of FILE that is not a link (, ), +# and in its style elements, in any quoting or none: scheme://host, +# //host, and the backslash forms a browser reads the same way. Knows no +# HTML beyond where a tag starts and ends, so it is wider than any parser +# and never quieter than one about a well formed page. +remote_tokens() { + tr '\n' ' ' < "$1" \ + | grep -oiE '<[a-z!/][^>]*>|]*>.*' \ + | grep -viE '^<(a|area)[[:space:]/>]' \ + | grep -oiE '([a-z][a-z0-9+.-]*:)?[/\\]{2}[^/\\[:space:]"'"'"')>]+' \ + || true } -@test "build is iso when the user agent has no tags" { - [ "$(tagid_build "$APT_LINE")" = iso ] +# serves_nothing_remote DIR +# Every file the fence would serve from DIR loads nothing from another host, +# by both detectors. +serves_nothing_remote() { + local file + for file in "$1"/*; do + if [[ -n "$(remote_tokens "$file")" ]]; then + echo "remote_tokens: $file: $(remote_tokens "$file")" >&2 + return 1 + fi + if [[ "$file" == *.html ]] && ! remote_loads "$file" >&2; then + echo "remote_loads: $file" >&2 + return 1 + fi + done } -@test "build joins the tags sorted and unique" { - local line='Acquire::http::User-Agent "TurnKey APT-HTTP/1.3 (turnkey-core-19.0-trixie-amd64 proxmox lxc lxc)";' - [ "$(tagid_build "$line")" = lxc-proxmox ] +# inherit PAGE_SUFFIX +# An htdocs directory left in /var by an older image or by somebody else: +# the packaged files, with PAGE_SUFFIX appended to the index. +inherit() { + mkdir -p "$(dirname "$HTDOCS")" + cp -R "$INITHOOKS_PATH/turnkey-init-fence/htdocs" "$(dirname "$HTDOCS")" + printf '%s\n' "$1" >> "$RENDERED" } -@test "is_tagged looks for the ajax host" { - echo 'fence' > "$BATS_TEST_TMPDIR/index.html" - run ! tagid_is_tagged "$BATS_TEST_TMPDIR/index.html" - echo "$SCRIPTS" >> "$BATS_TEST_TMPDIR/index.html" - tagid_is_tagged "$BATS_TEST_TMPDIR/index.html" +# ------------------------------------------------ the detectors themselves + +@test "both detectors see the scripts an older image appended" { + inherit "$OLD_SCRIPTS" + run remote_loads "$RENDERED" + [ "$status" -eq 1 ] + [ "${#lines[@]}" -eq 2 ] + run remote_tokens "$RENDERED" + [ "${#lines[@]}" -eq 2 ] } -@test "render_scripts prints the two script tags" { - run tagid_render_scripts iso 19.0-trixie-amd64 core +@test "remote_tokens sees what HTMLParser does not" { + inherit '-->' + run remote_loads "$RENDERED" [ "$status" -eq 0 ] - [ "$output" = "$SCRIPTS" ] + run remote_tokens "$RENDERED" + [ "$output" = "https://evil.example" ] } -# ------------------------------------------------------------------- hook +@test "remote_tokens ignores links and page text" { + run remote_tokens "$EXPECTED" + [ -z "$output" ] + grep -q 'href="https://www.turnkeylinux.org' "$EXPECTED" +} -@test "hook does nothing under turnkey-init" { - _TURNKEY_INIT=1 run "$HOOK" +# ------------------------------------------------- the pages that are served + +@test "the packaged pages load nothing from another host" { + run serves_nothing_remote "$REPO/turnkey-init-fence/htdocs" [ "$status" -eq 0 ] - [ -z "$output" ] - [ ! -e "$HTDOCS" ] - [ -z "$(calls systemctl)" ] } -@test "hook copies the packaged htdocs when the writable copy is missing" { +@test "the served pages load nothing from another host" { run "$HOOK" [ "$status" -eq 0 ] - [ -f "$HTDOCS/index.html" ] - run ! grep -q '@APP_NAME@' "$HTDOCS/index.html" - grep -q 'core' "$HTDOCS/index.html" - [ "$(tail -2 "$HTDOCS/index.html")" = "$SCRIPTS" ] - # the packaged copy is left as shipped - grep -q '@APP_NAME@' "$INITHOOKS_PATH/turnkey-init-fence/htdocs/index.html" + [ -s "$RENDERED" ] + run serves_nothing_remote "$HTDOCS" + [ "$status" -eq 0 ] +} + +@test "the page served is the packaged page, named after the appliance" { + "$HOOK" + diff "$EXPECTED" "$RENDERED" + run ! grep -q '@APP_NAME@' "$RENDERED" +} + +@test "a page inherited from an older image is replaced, not edited" { + inherit "$OLD_SCRIPTS" + "$HOOK" + diff "$EXPECTED" "$RENDERED" +} + +# The review of the first version of this branch measured these getting +# past a strip that removed double and single quoted ' + '' + '' + '' + '' + "" + '' + '' + '-->' + '' + '' + ) + local page + for page in "${pages[@]}"; do + rm -rf "$(dirname "$HTDOCS")" + inherit "$page" + [ -n "$(remote_tokens "$RENDERED")" ] + "$HOOK" + diff "$EXPECTED" "$RENDERED" + serves_nothing_remote "$HTDOCS" + done +} + +@test "an inherited stylesheet and a file the package does not ship are replaced too" { + inherit '' + echo '@import url(https://evil.example/x.css);' >> "$HTDOCS/style.css" + echo 'alert(1)' > "$HTDOCS/extra.js" + "$HOOK" + diff "$INITHOOKS_PATH/turnkey-init-fence/htdocs/style.css" "$HTDOCS/style.css" + [ ! -e "$HTDOCS/extra.js" ] + diff <(cd "$INITHOOKS_PATH/turnkey-init-fence/htdocs" && ls -A) \ + <(cd "$HTDOCS" && ls -A) +} + +@test "the page keeps its own local stylesheet and image" { + "$HOOK" + grep -q 'href="/style.css"' "$RENDERED" + grep -q 'src="/turnkey-init-root.png"' "$RENDERED" + cmp "$INITHOOKS_PATH/turnkey-init-fence/htdocs/turnkey-init-root.png" \ + "$HTDOCS/turnkey-init-root.png" +} + +@test "the inline script that fills in the ssh address survives" { + "$HOOK" + grep -q 'document.getElementById("p1")' "$RENDERED" + grep -q 'document.getElementById("p3")' "$RENDERED" + # it is inline: the element that holds it has no src + run ! grep -qE ']+src[^>]*>[[:space:]]*$' "$RENDERED" +} + +@test "running the hook twice leaves the same pages" { + "$HOOK" + cp -R "$HTDOCS" "$BATS_TEST_TMPDIR/once" + "$HOOK" + diff -r "$BATS_TEST_TMPDIR/once" "$HTDOCS" } -@test "hook tags an existing writable index" { - mkdir -p "$HTDOCS" - echo '@APP_NAME@' > "$HTDOCS/index.html" +@test "nothing is written inside the directory the fence serves, and nothing is left beside it" { + inherit "$OLD_SCRIPTS" + "$HOOK" + diff <(cd "$INITHOOKS_PATH/turnkey-init-fence/htdocs" && ls -A) \ + <(cd "$HTDOCS" && ls -A) + [ "$(ls -A "$(dirname "$HTDOCS")")" = htdocs ] +} + +@test "a render that fails leaves no inherited page to serve" { + # the fence serves the packaged htdocs when the writable copy is missing + # (fence_htdocs), so on failure the inherited copy goes rather than stays + inherit "$OLD_SCRIPTS" + stub sed 'exit 4' run "$HOOK" + [ "$status" -ne 0 ] + [[ "$output" == *"<3>"* ]] + [ ! -e "$HTDOCS" ] + [ -z "$(ls -A "$(dirname "$HTDOCS")")" ] +} + +@test "every step that can fail leaves no inherited page and nothing half built" { + local tool + for tool in mkdir mktemp cp chmod mv; do + rm -rf "$(dirname "$HTDOCS")" "${STUBS:?}/$tool" + inherit "$OLD_SCRIPTS" + stub "$tool" 'exit 9' + run "$HOOK" + [ "$status" -eq 1 ] || { echo "$tool: status $status" >&2; return 1; } + [ ! -e "$HTDOCS" ] || { echo "$tool: htdocs left" >&2; return 1; } + [ -z "$(ls -A "$(dirname "$HTDOCS")")" ] \ + || { echo "$tool: $(ls -A "$(dirname "$HTDOCS")")" >&2; return 1; } + rm "$STUBS/$tool" + done +} + +@test "a missing mktemp never turns the build directory into the root" { + # an empty NEW would make the copy land in /; it must stop instead. cp is + # a stub that succeeds, as the real one would for root + stub mktemp 'exit 1' + stub cp 'exit 0' + run "$HOOK" + [ "$status" -eq 1 ] + [ -z "$(calls cp)" ] +} + +@test "a default file without HTDOCS stops the hook before it removes anything" { + echo 'HTTP_PORTS=(80)' > "$INITFENCE_DEFAULT" + unset HTDOCS + run "$HOOK" + [ "$status" -eq 1 ] + [[ "$output" == *"HTDOCS is not set"* ]] +} + +@test "the page still ends with a closing html tag" { + "$HOOK" + grep -q '' "$RENDERED" +} + +# ------------------------------------------------------------- the library + +@test "app_name strips the turnkey prefix and the version" { + [ "$(tagid_app_name turnkey-core-19.0-trixie-amd64)" = core ] + [ "$(tagid_app_name turnkey-wordpress-19.0-trixie-amd64)" = wordpress ] + [ "$(tagid_app_name turnkey-gitea-19.1-trixie-arm64)" = gitea ] +} + +@test "the library no longer renders, detects or strips a remote tag" { + run ! declare -F tagid_render_scripts + run ! declare -F tagid_is_tagged + run ! declare -F tagid_build + run ! declare -F tagid_version + run ! declare -F tagid_strip_remote_scripts +} + +@test "the library names no third party host" { + run ! grep -qE 'ajax\.turnkeylinux\.org|googleapis\.com' "$REPO/lib/tagid.sh" + run ! grep -qE 'ajax\.turnkeylinux\.org|googleapis\.com' "$HOOK" +} + +# ---------------------------------------------------------------- the hook + +@test "hook serves the packaged page under turnkey-init as well" { + # turnkey-init is what an operator runs when the first boot did not + # complete, with the fence still up and the old page still in /var + inherit "$OLD_SCRIPTS" + _TURNKEY_INIT=1 run "$HOOK" [ "$status" -eq 0 ] - [ "$(cat "$HTDOCS/index.html")" = "core -$SCRIPTS" ] + diff "$EXPECTED" "$RENDERED" } -@test "hook does not append the scripts twice" { - mkdir -p "$HTDOCS" - printf 'core\n%s\n' "$SCRIPTS" > "$HTDOCS/index.html" +@test "hook copies the packaged htdocs when the writable copy is missing" { run "$HOOK" [ "$status" -eq 0 ] - [ "$(grep -c ajax.turnkeylinux.org "$HTDOCS/index.html")" -eq 2 ] + [ -f "$RENDERED" ] + run ! grep -q '@APP_NAME@' "$RENDERED" + grep -q 'core' "$RENDERED" + # the packaged copy is left as shipped + grep -q '@APP_NAME@' "$INITHOOKS_PATH/turnkey-init-fence/htdocs/index.html" } -@test "hook uses the build tags in the script urls" { - echo 'Acquire::http::User-Agent "TurnKey APT-HTTP/1.3 (turnkey-core-19.0-trixie-amd64 lxc)";' \ - > "$APT_CONF_TURNKEY" +@test "hook does not read the apt user agent any more" { + # common removes /etc/apt/apt.conf.d/01turnkey (Keel-Linux/common#6); the + # build tag it carried had no consumer but the scripts that are now gone + rm -f "$APT_CONF_TURNKEY" run "$HOOK" [ "$status" -eq 0 ] - grep -q 'initfence/lxc/19.0-trixie-amd64/core.js' "$HTDOCS/index.html" + [ -s "$RENDERED" ] } @test "hook exits 0 without reloading an inactive fence" { diff --git a/tests/test_remote_loads.py b/tests/test_remote_loads.py new file mode 100644 index 0000000..d0adab1 --- /dev/null +++ b/tests/test_remote_loads.py @@ -0,0 +1,155 @@ +# Copyright (c) 2026 TurnKey GNU/Linux +"""tests/remote_loads.py: every way a page can fetch from another host + +The script is loaded as a module and driven in process through its main() +so that coverage sees it. Each case is a page a browser would make fetch +something from another host on its own, before anybody clicks; each must be +reported. The cases that must not be reported are the ones the fence page +really carries: local resources, inline script, and links. +""" + +import contextlib +import importlib.util +import io +import os +import tempfile +import unittest +from os.path import abspath, dirname, join + +ROOT = dirname(dirname(abspath(__file__))) +SCRIPT = join(ROOT, "tests", "remote_loads.py") +PACKAGED = join(ROOT, "turnkey-init-fence", "htdocs", "index.html") + +_spec = importlib.util.spec_from_file_location("remote_loads", SCRIPT) +remote_loads = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(remote_loads) + +# every one of these makes a browser fetch from evil.example on load +HOSTILE = { + "double quoted script": '', + "single quoted script": "", + "unquoted script": "", + "upper case": '', + "script with a body": '', + "protocol relative": '', + "backslashes": '', + "slash backslash": '', + "scheme without slashes": '', + "tab in the scheme": '', + "leading space": '', + "entity encoded": '', + "stylesheet": '', + "preload": '', + "icon": '', + "image": '', + "srcset": '', + "picture source": '', + "iframe": '', + "object": '', + "embed": '', + "video poster": '', + "audio": '', + "track": '', + "input image": '', + "body background": '', + "svg image": '', + "svg xlink": '', + "base": '', + "meta refresh": '', + "style attribute": '
', + "style element url": "", + "style element import": '', + "style import url": "", + "font face": "", +} + +# none of these fetches from another host on its own +BENIGN = { + "local script": '', + "relative script": '', + "inline script": '', + "local stylesheet": '', + "local image": '', + "data image": '', + "link": '
Debian', + "area": '', + "form": '
', + "text": "

https://example.com

", + "local style url": '
', + "meta charset": '', + "meta refresh local": '', +} + + +class Hostile(unittest.TestCase): + def test_every_hostile_page_is_reported(self): + for name, page in HOSTILE.items(): + with self.subTest(name=name): + found = remote_loads.remote_loads(page) + self.assertTrue(found, f"not reported: {page}") + self.assertTrue( + any("evil.example" in url.lower() for url in found), found + ) + + def test_a_hostile_element_among_benign_ones_is_reported(self): + page = "".join(BENIGN.values()) + HOSTILE["unquoted script"] + self.assertEqual(remote_loads.remote_loads(page), + ["https://evil.example/x.js"]) + + +class Benign(unittest.TestCase): + def test_no_benign_page_is_reported(self): + for name, page in BENIGN.items(): + with self.subTest(name=name): + self.assertEqual(remote_loads.remote_loads(page), []) + + def test_the_packaged_page_loads_nothing_remote(self): + with open(PACKAGED, encoding="utf-8") as fob: + self.assertEqual(remote_loads.remote_loads(fob.read()), []) + + +class Main(unittest.TestCase): + def run_main(self, *argv): + out = io.StringIO() + err = io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + status = remote_loads.main(list(argv)) + return status, out.getvalue(), err.getvalue() + + def page(self, text): + fd, path = tempfile.mkstemp(suffix=".html") + with os.fdopen(fd, "w", encoding="utf-8") as fob: + fob.write(text) + self.addCleanup(os.remove, path) + return path + + def test_a_clean_page_exits_0_and_prints_nothing(self): + self.assertEqual(self.run_main(self.page(BENIGN["local script"])), + (0, "", "")) + + def test_a_remote_load_exits_1_and_prints_each_url(self): + page = self.page(HOSTILE["image"] + HOSTILE["iframe"]) + self.assertEqual( + self.run_main(page), + (1, "https://evil.example/x.png\nhttps://evil.example/\n", "")) + + def test_an_unreadable_page_exits_2(self): + status, out, err = self.run_main(join(ROOT, "no", "such", "page")) + self.assertEqual((status, out), (2, "")) + self.assertIn("remote_loads.py:", err) + + def test_undecodable_bytes_are_not_a_way_past_it(self): + path = self.page("") + with open(path, "wb") as fob: + fob.write(b"\xff\xfe") + status, out, _ = self.run_main(path) + self.assertEqual((status, out), (1, "//evil.example/x.js\n")) + + def test_wrong_usage_exits_2(self): + status, out, err = self.run_main() + self.assertEqual((status, out), (2, "")) + self.assertIn("usage", err) + + +if __name__ == "__main__": + unittest.main() diff --git a/turnkey-init-fence/htdocs/index.html b/turnkey-init-fence/htdocs/index.html index 1c696a0..07f2286 100644 --- a/turnkey-init-fence/htdocs/index.html +++ b/turnkey-init-fence/htdocs/index.html @@ -71,7 +71,6 @@

More information and support

-