aboutsummaryrefslogtreecommitdiff
path: root/tests/test_url_security.py
blob: dee38dd240fb31eaffdbf913e384ba6f050b9ea9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me>
#
# SPDX-License-Identifier: BSD-3-Clause

from unittest.mock import Mock, patch

import dns.resolver
import pytest

from webmentions_ssg.url_security import (
    AddressResolutionError,
    is_http_url,
    is_public_url,
)


@pytest.mark.parametrize(
    ("url", "expected"),
    [
        pytest.param("http://example.com/", True, id="http"),
        pytest.param("https://example.com/path", True, id="https"),
        pytest.param("HTTP://EXAMPLE.COM/", True, id="scheme-case-insensitive"),
        pytest.param("https://example.com:8443/path", True, id="port"),
        pytest.param("http://[2001:db8::1]/", True, id="ipv6"),
        pytest.param("ftp://example.com/", False, id="ftp"),
        pytest.param("mailto:example@example.com", False, id="mailto"),
        pytest.param("/relative/url", False, id="relative"),
        pytest.param("//example.com/path", False, id="scheme-relative"),
        pytest.param("https:///missing-host", False, id="missing-host"),
        pytest.param("", False, id="empty"),
        pytest.param("http://[::1", False, id="malformed"),
    ],
)
def test_is_http_url(url: str, expected: bool) -> None:
    assert is_http_url(url) is expected


@patch("webmentions_ssg.url_security.dns.resolver.resolve_name")
@pytest.mark.parametrize(
    ("url", "resolved_addresses"),
    [
        ("http://127.0.0.1/", ["127.0.0.1"]),
        ("http://127.0.0.1:8080/test", ["127.0.0.1"]),
        ("http://[::1]/", ["::1"]),
        ("http://10.0.0.1/", ["10.0.0.1"]),
        ("http://172.16.0.1/", ["172.16.0.1"]),
        ("http://192.168.1.1/", ["192.168.1.1"]),
        ("http://169.254.169.254/", ["169.254.169.254"]),
        ("http://localhost/", ["127.0.0.1", "::1"]),
        ("http://internal.example/", ["192.168.1.10"]),
        ("https://example.com/", ["93.184.216.34", "192.168.1.10"]),
    ],
)
def test_is_public_url_returns_false_for_non_public_addresses(
    resolve_name: Mock, url: str, resolved_addresses: list[str]
) -> None:
    resolve_name.return_value.addresses.return_value = resolved_addresses
    assert not is_public_url(url)


@patch("webmentions_ssg.url_security.dns.resolver.resolve_name")
@pytest.mark.parametrize(
    ("url", "resolved_addresses"),
    [
        ("https://example.com/", ["93.184.216.34"]),
        (
            "https://example.com/",
            ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"],
        ),
    ],
)
def test_is_public_url_returns_true_for_public_addresses(
    resolve_name: Mock, url: str, resolved_addresses: list[str]
) -> None:
    resolve_name.return_value.addresses.return_value = resolved_addresses
    assert is_public_url(url)


@patch(
    "webmentions_ssg.url_security.dns.resolver.resolve_name",
    side_effect=dns.resolver.NXDOMAIN(),
)
def test_is_public_url_raises_for_resolution_failure(resolve_name: Mock) -> None:
    with pytest.raises(AddressResolutionError, match="Could not resolve hostname"):
        is_public_url("https://nonexistent.example/")
    resolve_name.assert_called_once_with("nonexistent.example")


@patch("webmentions_ssg.url_security.dns.resolver.resolve_name")
def test_is_public_url_raises_when_url_has_no_hostname(resolve_name: Mock) -> None:
    with pytest.raises(ValueError, match="No hostname was specified"):
        is_public_url("/relative/url")
    resolve_name.assert_not_called()