hstsheader 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hstsheader/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """Parse, check and build the HTTP Strict-Transport-Security header, typed and zero-dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._build import build
6
+ from ._lint import lint
7
+ from ._model import DELTA_SECONDS_MAX, PRELOAD_MIN_MAX_AGE, Hsts, HstsError, Issue
8
+ from ._parse import parse
9
+ from ._preload import is_preloadable, preload_issues
10
+
11
+ __version__ = "0.2.0"
12
+
13
+ __all__ = [
14
+ "DELTA_SECONDS_MAX",
15
+ "PRELOAD_MIN_MAX_AGE",
16
+ "Hsts",
17
+ "HstsError",
18
+ "Issue",
19
+ "build",
20
+ "is_preloadable",
21
+ "lint",
22
+ "parse",
23
+ "preload_issues",
24
+ ]
hstsheader/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Allow ``python -m hstsheader``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
hstsheader/_build.py ADDED
@@ -0,0 +1,78 @@
1
+ """Render a Strict-Transport-Security header value that cannot carry an injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ from ._chars import ascii_lower, is_sendable, is_token, quote
8
+ from ._model import Hsts, HstsError
9
+
10
+ _KNOWN = frozenset({"max-age", "includesubdomains", "preload"})
11
+
12
+
13
+ def _extension(name: object, value: object, seen: set[str]) -> str:
14
+ if not isinstance(name, str):
15
+ raise HstsError(f"extension name {name!r} is not an HTTP token", code="build.name")
16
+ # Check and write plain str copies: a str subclass could lie in __iter__ or __format__.
17
+ name = str.__str__(name)
18
+ if not is_token(name):
19
+ raise HstsError(f"extension name {name!r} is not an HTTP token", code="build.name")
20
+ key = ascii_lower(name)
21
+ if key in _KNOWN:
22
+ raise HstsError(
23
+ f"{name} is set by its own argument, not as an extension", code="build.name"
24
+ )
25
+ if key in seen:
26
+ raise HstsError(f"extension {name} is given twice", code="duplicate")
27
+ seen.add(key)
28
+ if value is None:
29
+ return name
30
+ if not isinstance(value, str):
31
+ raise HstsError(f"extension {name} needs a str value or None", code="type")
32
+ value = str.__str__(value)
33
+ if is_token(value):
34
+ return f"{name}={value}"
35
+ if not is_sendable(value):
36
+ message = f"extension {name} has a control or non-ASCII character in its value"
37
+ raise HstsError(message, code="build.value")
38
+ return f"{name}={quote(value)}"
39
+
40
+
41
+ def build(
42
+ max_age: int | Hsts,
43
+ *,
44
+ include_subdomains: bool | None = None,
45
+ preload: bool | None = None,
46
+ extensions: Mapping[str, str | None] | None = None,
47
+ ) -> str:
48
+ """Build a Strict-Transport-Security header value.
49
+
50
+ Pass an :class:`Hsts`, or ``max_age`` (an ``int`` from 0 to
51
+ :data:`DELTA_SECONDS_MAX`) with the ``include_subdomains`` and ``preload`` flags.
52
+ ``extensions`` maps further directive names (HTTP tokens other than the three known
53
+ ones) to a value, or to ``None`` for a valueless directive; values that are not
54
+ tokens are written as quoted strings, and control or non-ASCII characters are
55
+ refused, so the result is always one well-formed field value. Directives come out
56
+ in a fixed order: max-age, includeSubDomains, preload, then the extensions.
57
+ """
58
+ if isinstance(max_age, Hsts):
59
+ if include_subdomains is not None or preload is not None:
60
+ raise TypeError("pass the flags on the Hsts object, not as arguments")
61
+ policy = max_age
62
+ else:
63
+ policy = Hsts(
64
+ max_age,
65
+ False if include_subdomains is None else include_subdomains,
66
+ False if preload is None else preload,
67
+ )
68
+ parts = [f"max-age={policy.max_age}"]
69
+ if policy.include_subdomains:
70
+ parts.append("includeSubDomains")
71
+ if policy.preload:
72
+ parts.append("preload")
73
+ if extensions is not None and not isinstance(extensions, Mapping):
74
+ raise HstsError("extensions must be a mapping of name to value", code="type")
75
+ seen: set[str] = set()
76
+ for name, value in (extensions or {}).items():
77
+ parts.append(_extension(name, value, seen))
78
+ return "; ".join(parts)
hstsheader/_chars.py ADDED
@@ -0,0 +1,49 @@
1
+ """Character classes from RFC 9110 section 5.6 and RFC 2616 section 2.2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import string
6
+
7
+ # tchar: any visible US-ASCII character except the delimiters.
8
+ TCHAR = frozenset(string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~")
9
+ DIGITS = frozenset(string.digits)
10
+ OWS = " \t"
11
+
12
+ # Upper-case ASCII only: str.lower() would also fold non-ASCII letters (the Kelvin sign
13
+ # becomes "k"), which must never turn into a directive name.
14
+ _ASCII_LOWER = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
15
+
16
+
17
+ def ascii_lower(text: str) -> str:
18
+ return text.translate(_ASCII_LOWER)
19
+
20
+
21
+ def is_token(text: str) -> bool:
22
+ return bool(text) and all(char in TCHAR for char in text)
23
+
24
+
25
+ def is_qdtext(char: str) -> bool:
26
+ code = ord(char)
27
+ return (
28
+ char in OWS
29
+ or code == 0x21
30
+ or 0x23 <= code <= 0x5B
31
+ or 0x5D <= code <= 0x7E
32
+ or 0x80 <= code <= 0xFF
33
+ )
34
+
35
+
36
+ def is_pair_char(char: str) -> bool:
37
+ """The octet allowed after a backslash in a quoted-pair."""
38
+ code = ord(char)
39
+ return char in OWS or 0x21 <= code <= 0x7E or 0x80 <= code <= 0xFF
40
+
41
+
42
+ def is_sendable(text: str) -> bool:
43
+ """True when a sender may put the text in a quoted string: HTAB, SP and VCHAR only."""
44
+ return all(char in OWS or 0x21 <= ord(char) <= 0x7E for char in text)
45
+
46
+
47
+ def quote(text: str) -> str:
48
+ """Write a quoted-string, escaping only the backslash and the double quote."""
49
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
hstsheader/_cli.py ADDED
@@ -0,0 +1,79 @@
1
+ """Command line: check a Strict-Transport-Security value or a response head, print JSON."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from collections.abc import Sequence
9
+
10
+ from ._head import field_lines
11
+ from ._lint import analyse
12
+ from ._model import Issue
13
+ from ._preload import preload_issues
14
+
15
+ EXIT_REJECTED = 1
16
+ EXIT_USAGE = 2
17
+
18
+
19
+ def _issue(issue: Issue) -> dict[str, object]:
20
+ return {
21
+ "code": issue.code,
22
+ "severity": issue.severity,
23
+ "offset": issue.offset,
24
+ "message": issue.message,
25
+ }
26
+
27
+
28
+ def main(argv: Sequence[str] | None = None) -> int:
29
+ """Run the command line; returns the process exit code."""
30
+ parser = argparse.ArgumentParser(
31
+ prog="hstsheader",
32
+ description="Check a Strict-Transport-Security value, or the response head read from "
33
+ "standard input (for example the output of curl -sI). Prints the policy and the "
34
+ "findings as JSON. Exit status 0 means a user agent that follows RFC 6797 honours "
35
+ "the header (with --preload: that it meets the preload list rules), 1 that it does "
36
+ "not, 2 a usage error such as input without the field.",
37
+ )
38
+ parser.add_argument("value", nargs="?", help="a Strict-Transport-Security field value")
39
+ parser.add_argument(
40
+ "--preload", action="store_true", help="also check the hstspreload.org requirements"
41
+ )
42
+ args = parser.parse_args(argv)
43
+ if args.value is None and sys.stdin.isatty():
44
+ parser.error("give a value, or pipe a response head (curl -sI URL | hstsheader)")
45
+ # Latin-1 keeps every octet of a raw head, obs-text included.
46
+ text = args.value if args.value is not None else sys.stdin.buffer.read().decode("latin-1")
47
+ fields = field_lines(text)
48
+ if fields is None:
49
+ bare = text.strip(" \t\r\n")
50
+ # An empty argument is a value to check; empty standard input is a usage error.
51
+ fields = [bare] if bare or args.value is not None else []
52
+ if not fields:
53
+ print("hstsheader: no Strict-Transport-Security field in the input", file=sys.stderr)
54
+ return EXIT_USAGE
55
+ analysis = analyse(fields)
56
+ policy = None
57
+ if analysis.max_age is not None:
58
+ policy = {
59
+ "max_age": analysis.max_age,
60
+ "include_subdomains": analysis.include_subdomains,
61
+ "preload": analysis.preload,
62
+ }
63
+ report: dict[str, object] = {
64
+ "field": fields[0],
65
+ "honoured": not analysis.errors,
66
+ "policy": policy,
67
+ "issues": [_issue(issue) for issue in analysis.issues],
68
+ }
69
+ ok = not analysis.errors
70
+ if args.preload:
71
+ every = preload_issues(fields)
72
+ found = [i for i in every if i.code.startswith("preload.")]
73
+ preloadable = not any(i.severity == "error" for i in every)
74
+ report["preloadable"] = preloadable
75
+ report["preload_issues"] = [_issue(issue) for issue in found]
76
+ ok = preloadable
77
+ json.dump(report, sys.stdout, indent=2)
78
+ sys.stdout.write("\n")
79
+ return 0 if ok else EXIT_REJECTED
hstsheader/_head.py ADDED
@@ -0,0 +1,59 @@
1
+ """Find the Strict-Transport-Security field lines in a response head such as curl -sI prints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _FIELD = "strict-transport-security"
8
+ _FIELD_LINE = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]+:")
9
+
10
+
11
+ def _responses(lines: list[str]) -> list[list[str]]:
12
+ """Split lines into heads: each starts at a status line and ends at a blank line."""
13
+ heads: list[list[str]] = []
14
+ current: list[str] | None = None
15
+ for line in lines:
16
+ if line.startswith("HTTP/"):
17
+ current = []
18
+ heads.append(current)
19
+ elif not line.strip(" \t"):
20
+ current = None # the head is over; a body may follow
21
+ elif current is not None:
22
+ current.append(line)
23
+ return heads
24
+
25
+
26
+ def _unfold(lines: list[str]) -> list[str]:
27
+ """Join obsolete folded lines (starting with SP or HTAB) onto the line before."""
28
+ out: list[str] = []
29
+ for line in lines:
30
+ if line[:1] in (" ", "\t") and out:
31
+ out[-1] += " " + line.strip(" \t")
32
+ else:
33
+ out.append(line)
34
+ return out
35
+
36
+
37
+ def field_lines(text: str) -> list[str] | None:
38
+ """Return the STS values of the last response in ``text``, or None for a bare value.
39
+
40
+ Input counts as a head when its first non-empty line is a status line or a field
41
+ line (``name: value``). With several responses (``curl -sIL`` follows redirects) the
42
+ last one is used, because that is the response the browser ends up keeping.
43
+ """
44
+ lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
45
+ while lines and not lines[0].strip(" \t"):
46
+ lines.pop(0)
47
+ if not lines:
48
+ return None
49
+ if not lines[0].startswith("HTTP/"):
50
+ if not _FIELD_LINE.match(lines[0]):
51
+ return None
52
+ lines.insert(0, "HTTP/") # pasted fields without a status line
53
+ heads = _responses(lines)
54
+ values: list[str] = []
55
+ for line in _unfold(heads[-1]):
56
+ name, sep, value = line.partition(":")
57
+ if sep and name.lower() == _FIELD:
58
+ values.append(value.strip(" \t"))
59
+ return values
hstsheader/_lint.py ADDED
@@ -0,0 +1,166 @@
1
+ """Check a header value against RFC 6797 and collect what a reader can recover from it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass
7
+
8
+ from ._chars import DIGITS
9
+ from ._model import DELTA_SECONDS_MAX, Issue
10
+ from ._scan import Directive, scan
11
+
12
+ _VALUELESS = {"includesubdomains": "includeSubDomains", "preload": "preload"}
13
+ # Chromium refuses a header only when these repeat; the preload checker only warns otherwise.
14
+ _HARD_REPEATS = frozenset({"max-age", "includesubdomains"})
15
+ # The hstspreload.org checker reads max-age as a 64-bit unsigned integer.
16
+ _UINT64_MAX = 2**64 - 1
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class Analysis:
21
+ """Everything known about one header: findings plus the tolerant reading.
22
+
23
+ The ``max_age_*`` flags describe the max-age directive that was used.
24
+ ``soft_repeats`` holds the offsets of repeated directives other than max-age and
25
+ includeSubDomains: errors for RFC 6797, tolerated by Chromium and the preload checker.
26
+ """
27
+
28
+ issues: tuple[Issue, ...]
29
+ max_age: int | None = None
30
+ include_subdomains: bool = False
31
+ preload: bool = False
32
+ max_age_quoted: bool = False
33
+ max_age_spaced: bool = False
34
+ max_age_beyond_uint64: bool = False
35
+ soft_repeats: frozenset[int] = frozenset()
36
+
37
+ @property
38
+ def errors(self) -> tuple[Issue, ...]:
39
+ return tuple(issue for issue in self.issues if issue.severity == "error")
40
+
41
+
42
+ def delta_seconds(digits: str) -> tuple[int, bool]:
43
+ """Read ASCII digits as seconds; values past 2^31 become 2^31 (flag set)."""
44
+ significant = digits.lstrip("0") or "0"
45
+ # Never convert a long digit string: int() refuses more than 4300 digits.
46
+ if len(significant) > len(str(DELTA_SECONDS_MAX)):
47
+ return DELTA_SECONDS_MAX, True
48
+ value = int(significant)
49
+ return (DELTA_SECONDS_MAX, True) if value > DELTA_SECONDS_MAX else (value, False)
50
+
51
+
52
+ def _beyond_uint64(digits: str) -> bool:
53
+ significant = digits.lstrip("0")
54
+ return len(significant) > len(str(_UINT64_MAX)) or int(significant or "0") > _UINT64_MAX
55
+
56
+
57
+ def _max_age(directive: Directive, issues: list[Issue]) -> int | None:
58
+ value, offset = directive.value, directive.value_offset
59
+ if value is None:
60
+ issues.append(Issue("max-age.no-value", "max-age needs a value", "error", directive.offset))
61
+ return None
62
+ if not value or not all(char in DIGITS for char in value):
63
+ message = f"max-age must be ASCII digits, got {value!r}"
64
+ issues.append(Issue("max-age.not-digits", message, "error", offset))
65
+ return None
66
+ seconds, capped = delta_seconds(value)
67
+ if len(value) > 1 and value[0] == "0":
68
+ issues.append(
69
+ Issue("max-age.leading-zero", "max-age has a leading zero", "warning", offset)
70
+ )
71
+ if capped:
72
+ message = f"max-age is larger than {DELTA_SECONDS_MAX} and is read as that value"
73
+ issues.append(Issue("max-age.capped", message, "warning", offset))
74
+ if directive.quoted:
75
+ message = "max-age is quoted; valid, but the hstspreload.org checker refuses it"
76
+ issues.append(Issue("max-age.quoted", message, "warning", offset))
77
+ if directive.spaced:
78
+ message = "space around '=' in max-age; valid, but the hstspreload.org checker refuses it"
79
+ issues.append(Issue("max-age.spaced", message, "warning", directive.offset))
80
+ return seconds
81
+
82
+
83
+ def _first_field(value: str | Iterable[str]) -> tuple[str | None, int]:
84
+ if isinstance(value, (bytes, bytearray, memoryview)):
85
+ raise TypeError("pass the header as str; decode bytes with latin-1 first")
86
+ if isinstance(value, str):
87
+ return value, 1
88
+ if isinstance(value, Mapping) or not isinstance(value, Iterable):
89
+ raise TypeError(f"pass a str or a list of str, got {type(value).__name__}")
90
+ fields = list(value)
91
+ for item in fields:
92
+ if not isinstance(item, str):
93
+ raise TypeError(f"field lines must be str, got {type(item).__name__}")
94
+ return (fields[0] if fields else None), len(fields)
95
+
96
+
97
+ def analyse(value: str | Iterable[str]) -> Analysis:
98
+ """Scan the first field line of ``value`` and judge each directive."""
99
+ text, count = _first_field(value)
100
+ if text is None:
101
+ return Analysis((Issue("field.missing", "no Strict-Transport-Security field", "error"),))
102
+ found = scan(text)
103
+ issues = list(found.issues)
104
+ if count > 1:
105
+ message = f"{count} field lines; a user agent processes only the first (RFC 6797 8.1)"
106
+ issues.append(Issue("multiple-fields", message, "warning"))
107
+ # Broken directives count for repeats too, in the order they appear.
108
+ order: list[tuple[int, str, Directive | None]] = [
109
+ (d.offset, d.key, d) for d in found.directives
110
+ ]
111
+ order += [(offset, key, None) for offset, key in found.broken]
112
+ seen: set[str] = set()
113
+ soft: set[int] = set()
114
+ used: Directive | None = None
115
+ max_age: int | None = None
116
+ flags = {key: False for key in _VALUELESS}
117
+ for offset, key, directive in sorted(order, key=lambda item: item[0]):
118
+ if key in seen:
119
+ name = directive.name if directive else key
120
+ message = f"{name} appears more than once; every directive must be unique"
121
+ issues.append(Issue("duplicate", message, "error", offset))
122
+ if key not in _HARD_REPEATS:
123
+ soft.add(offset)
124
+ seen.add(key)
125
+ if directive is None:
126
+ continue
127
+ if key == "max-age":
128
+ seconds = _max_age(directive, issues)
129
+ if max_age is None and seconds is not None:
130
+ max_age, used = seconds, directive
131
+ elif key in _VALUELESS:
132
+ if directive.value is not None:
133
+ message = f"{_VALUELESS[key]} takes no value"
134
+ issues.append(Issue("value-not-allowed", message, "error", offset))
135
+ else:
136
+ flags[key] = True
137
+ else:
138
+ message = f"unknown directive {directive.name} is ignored"
139
+ issues.append(Issue("unknown-directive", message, "warning", offset))
140
+ if "max-age" not in seen:
141
+ issues.append(Issue("max-age.missing", "max-age is required", "error"))
142
+ if max_age == 0 and flags["includesubdomains"]:
143
+ message = "max-age=0 removes the policy; includeSubDomains has no effect"
144
+ issues.append(Issue("max-age.zero-subdomains", message, "warning"))
145
+ issues.sort(key=lambda issue: (issue.offset is None, issue.offset or 0))
146
+ return Analysis(
147
+ tuple(issues),
148
+ max_age,
149
+ flags["includesubdomains"],
150
+ flags["preload"],
151
+ used is not None and used.quoted,
152
+ used is not None and used.spaced,
153
+ used is not None and _beyond_uint64(used.value or ""),
154
+ frozenset(soft),
155
+ )
156
+
157
+
158
+ def lint(value: str | Iterable[str]) -> tuple[Issue, ...]:
159
+ """List every finding about a Strict-Transport-Security value, in field order.
160
+
161
+ Pass one field value, or every field line of a response (only the first is
162
+ processed, as RFC 6797 section 8.1 requires). Findings with severity ``error`` mean a
163
+ user agent that follows RFC 6797 ignores the header; an empty tuple means a clean
164
+ header. Chromium is more lenient in places (see the README).
165
+ """
166
+ return analyse(value).issues
hstsheader/_model.py ADDED
@@ -0,0 +1,79 @@
1
+ """The parsed form of a Strict-Transport-Security header, its findings and its error type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ # RFC 9111 section 1.2.2: a delta-seconds value too large to represent is read as 2^31.
9
+ DELTA_SECONDS_MAX = 2147483648
10
+
11
+ # hstspreload.org: the header on the base domain needs a max-age of at least one year.
12
+ PRELOAD_MIN_MAX_AGE = 31536000
13
+
14
+ Severity = Literal["error", "warning"]
15
+
16
+
17
+ class HstsError(ValueError):
18
+ """Raised when a header cannot be read or a value is not valid.
19
+
20
+ ``code`` names the rule that failed (the same codes as :class:`Issue`) and
21
+ ``offset`` is the index in the field value where it failed, when there is one.
22
+ """
23
+
24
+ def __init__(self, message: str, *, code: str = "invalid", offset: int | None = None) -> None:
25
+ super().__init__(message if offset is None else f"{message} (at offset {offset})")
26
+ self.code = code
27
+ self.offset = offset
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Issue:
32
+ """One finding about a header value.
33
+
34
+ An ``error`` means a conforming user agent ignores the whole header; a ``warning``
35
+ marks something legal that is likely a mistake or is refused by some checkers.
36
+ """
37
+
38
+ code: str
39
+ message: str
40
+ severity: Severity
41
+ offset: int | None = None
42
+
43
+
44
+ def _check_flag(name: str, value: object) -> None:
45
+ if not isinstance(value, bool):
46
+ raise HstsError(f"{name} must be a bool, got {type(value).__name__}", code="type")
47
+
48
+
49
+ def check_max_age(value: object) -> int:
50
+ """Return ``value`` when it is a valid max-age for a sender, else raise."""
51
+ if isinstance(value, bool) or not isinstance(value, int):
52
+ raise HstsError("max_age must be an integer number of seconds", code="type")
53
+ if value < 0:
54
+ raise HstsError("max_age must not be negative", code="max-age.negative")
55
+ if value > DELTA_SECONDS_MAX:
56
+ raise HstsError(
57
+ f"max_age must not exceed {DELTA_SECONDS_MAX}; recipients read larger values as that",
58
+ code="max-age.too-large",
59
+ )
60
+ return value
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class Hsts:
65
+ """A Strict-Transport-Security policy: a max-age and two optional flags.
66
+
67
+ The fields are checked on creation: ``max_age`` is an ``int`` from 0 to
68
+ :data:`DELTA_SECONDS_MAX` and both flags are ``bool``.
69
+ """
70
+
71
+ max_age: int
72
+ include_subdomains: bool = False
73
+ preload: bool = False
74
+
75
+ def __post_init__(self) -> None:
76
+ # Keep a plain int: an int subclass could render itself as anything in a header.
77
+ object.__setattr__(self, "max_age", int.__index__(check_max_age(self.max_age)))
78
+ _check_flag("include_subdomains", self.include_subdomains)
79
+ _check_flag("preload", self.preload)
hstsheader/_parse.py ADDED
@@ -0,0 +1,36 @@
1
+ """Read a Strict-Transport-Security header value into an :class:`Hsts`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from ._lint import analyse
8
+ from ._model import Hsts, HstsError
9
+
10
+
11
+ def parse(header: str | Iterable[str], *, strict: bool = False) -> Hsts:
12
+ """Parse a Strict-Transport-Security header value.
13
+
14
+ ``header`` is one field value, or the field lines of a response in order (only the
15
+ first is read, RFC 6797 section 8.1). Directive names match ASCII case-insensitively,
16
+ whitespace around ``;`` and ``=`` is allowed, unknown directives are ignored and
17
+ ``max-age`` may be quoted. Values above :data:`DELTA_SECONDS_MAX` are read as it.
18
+
19
+ With ``strict=True`` the header must be one a conforming user agent honours: any
20
+ finding of severity ``error`` from :func:`lint` raises :class:`HstsError` with its
21
+ code and offset. By default the reader is tolerant: it skips broken directives, keeps
22
+ the first valid ``max-age`` and raises only when no valid ``max-age`` is left.
23
+ """
24
+ analysis = analyse(header)
25
+ if strict and analysis.errors:
26
+ first = analysis.errors[0]
27
+ raise HstsError(first.message, code=first.code, offset=first.offset)
28
+ if analysis.max_age is None:
29
+ # analyse() always records an error when no max-age is usable: a max-age finding,
30
+ # or the syntax error that made the max-age directive unreadable.
31
+ reason = next(
32
+ (i for i in analysis.errors if i.code.startswith(("max-age", "field"))),
33
+ analysis.errors[0],
34
+ )
35
+ raise HstsError(reason.message, code=reason.code, offset=reason.offset)
36
+ return Hsts(analysis.max_age, analysis.include_subdomains, analysis.preload)
hstsheader/_preload.py ADDED
@@ -0,0 +1,74 @@
1
+ """Check a header against the hstspreload.org submission requirements."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from ._lint import Analysis, analyse
8
+ from ._model import PRELOAD_MIN_MAX_AGE, Hsts, Issue
9
+
10
+ # hstspreload.org warns above ten years of 365 days.
11
+ _TEN_YEARS = 10 * 365 * 86400
12
+
13
+
14
+ def _form_issues(analysis: Analysis) -> list[Issue]:
15
+ """RFC errors, with repeats the site tolerates turned into warnings, plus its own rules."""
16
+ issues = [
17
+ Issue(i.code, i.message, "warning", i.offset)
18
+ if i.code == "duplicate" and i.offset in analysis.soft_repeats
19
+ else i
20
+ for i in analysis.errors
21
+ ]
22
+ if analysis.max_age_quoted:
23
+ message = "hstspreload.org does not accept a quoted max-age"
24
+ issues.append(Issue("preload.max-age-quoted", message, "error"))
25
+ if analysis.max_age_spaced:
26
+ message = "hstspreload.org does not accept space around '=' in max-age"
27
+ issues.append(Issue("preload.max-age-spaced", message, "error"))
28
+ if analysis.max_age_beyond_uint64:
29
+ message = "hstspreload.org cannot read a max-age above 2^64 - 1"
30
+ issues.append(Issue("preload.max-age-unreadable", message, "error"))
31
+ return issues
32
+
33
+
34
+ def preload_issues(header: Hsts | str | Iterable[str]) -> tuple[Issue, ...]:
35
+ """List what keeps a header from the HSTS preload list.
36
+
37
+ A header value is first checked against RFC 6797: its errors are included, except a
38
+ repeated directive other than max-age and includeSubDomains, which Chromium and the
39
+ site's checker tolerate and which comes back as a warning. An :class:`Hsts` is taken
40
+ as already valid. Then the hstspreload.org rules apply: ``includeSubDomains`` and
41
+ ``preload`` present, and a ``max-age`` of at least :data:`PRELOAD_MIN_MAX_AGE`,
42
+ written the way the site's checker reads it (unquoted, no space around ``=``, at most
43
+ 2^64 - 1). A ``max-age`` above ten years is a warning. An empty tuple or warnings only
44
+ means the header qualifies.
45
+ """
46
+ if isinstance(header, Hsts):
47
+ issues: list[Issue] = []
48
+ max_age: int | None = header.max_age
49
+ subdomains, preload = header.include_subdomains, header.preload
50
+ else:
51
+ analysis = analyse(header)
52
+ issues = _form_issues(analysis)
53
+ max_age = analysis.max_age
54
+ subdomains, preload = analysis.include_subdomains, analysis.preload
55
+ if not subdomains:
56
+ message = "the includeSubDomains directive is required"
57
+ issues.append(Issue("preload.subdomains-missing", message, "error"))
58
+ if not preload:
59
+ issues.append(Issue("preload.missing", "the preload directive is required", "error"))
60
+ if max_age == 0:
61
+ message = "max-age=0 asks for removal; use the hstspreload.org removal form instead"
62
+ issues.append(Issue("preload.max-age-zero", message, "error"))
63
+ elif max_age is not None and max_age < PRELOAD_MIN_MAX_AGE:
64
+ message = f"max-age must be at least {PRELOAD_MIN_MAX_AGE} seconds (one year)"
65
+ issues.append(Issue("preload.max-age-too-short", message, "error"))
66
+ elif max_age is not None and max_age > _TEN_YEARS:
67
+ message = "max-age is longer than ten years, an unusually long value"
68
+ issues.append(Issue("preload.max-age-over-ten-years", message, "warning"))
69
+ return tuple(issues)
70
+
71
+
72
+ def is_preloadable(header: Hsts | str | Iterable[str]) -> bool:
73
+ """True when :func:`preload_issues` finds no error."""
74
+ return not any(issue.severity == "error" for issue in preload_issues(header))
hstsheader/_scan.py ADDED
@@ -0,0 +1,180 @@
1
+ """Split a field value into directives following the RFC 6797 section 6.1 grammar.
2
+
3
+ Strict-Transport-Security = [ directive ] *( ";" [ directive ] )
4
+ directive = directive-name [ "=" directive-value ]
5
+ directive-name = token
6
+ directive-value = token | quoted-string
7
+
8
+ Whitespace may appear between any two words and separators (RFC 2616 section 2.2,
9
+ "implied *LWS"), and a folded line (CRLF followed by SP or HTAB) counts as whitespace, as
10
+ RFC 2616 LWS and RFC 9112 section 5.2 allow. A quoted-pair follows RFC 9110 section 5.6.4
11
+ (HTAB, SP, VCHAR or obs-text after the backslash), stricter on purpose than RFC 2616, which
12
+ let any US-ASCII character follow. A directive that breaks the grammar is reported and
13
+ skipped up to the next semicolon that is not inside a quoted string, so the rest can still
14
+ be read.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from dataclasses import dataclass, field
21
+
22
+ from ._chars import OWS, TCHAR, ascii_lower, is_pair_char, is_qdtext
23
+ from ._model import Issue
24
+
25
+ # RFC 9110 section 5.5: CR, LF and NUL are invalid; a recipient may replace them with SP.
26
+ _REPLACED = str.maketrans({"\r": " ", "\n": " ", "\0": " "})
27
+ _FOLD = re.compile(r"\r?\n(?=[ \t])")
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Directive:
32
+ name: str
33
+ key: str
34
+ value: str | None
35
+ quoted: bool
36
+ offset: int
37
+ value_offset: int
38
+ # Whitespace around "=": legal, but the hstspreload.org checker refuses it for max-age.
39
+ spaced: bool = False
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class Scan:
44
+ directives: list[Directive] = field(default_factory=list)
45
+ issues: list[Issue] = field(default_factory=list)
46
+ # (offset, lower-cased name) of directives skipped because they broke the grammar.
47
+ broken: list[tuple[int, str]] = field(default_factory=list)
48
+
49
+
50
+ class _Broken(Exception):
51
+ def __init__(self, message: str, offset: int, name: str = "") -> None:
52
+ super().__init__(message)
53
+ self.message = message
54
+ self.offset = offset
55
+ self.name = name
56
+
57
+
58
+ def _skip_ows(text: str, i: int) -> int:
59
+ while i < len(text) and text[i] in OWS:
60
+ i += 1
61
+ return i
62
+
63
+
64
+ def _quoted(text: str, start: int) -> tuple[str, int]:
65
+ """Read the quoted-string opening at ``start``; return its value and the end index."""
66
+ out: list[str] = []
67
+ i = start + 1
68
+ while i < len(text):
69
+ char = text[i]
70
+ if char == '"':
71
+ return "".join(out), i + 1
72
+ if char == "\\":
73
+ if i + 1 >= len(text):
74
+ break
75
+ if not is_pair_char(text[i + 1]):
76
+ raise _Broken(f"{_show(text[i + 1])} cannot follow a backslash", i + 1)
77
+ out.append(text[i + 1])
78
+ i += 2
79
+ continue
80
+ if not is_qdtext(char):
81
+ raise _Broken(f"{_show(char)} is not allowed in a quoted string", i)
82
+ out.append(char)
83
+ i += 1
84
+ raise _Broken("unterminated quoted string", start)
85
+
86
+
87
+ def _show(char: str) -> str:
88
+ return f"U+{ord(char):04X}" if not char.isprintable() or not char.isascii() else repr(char)
89
+
90
+
91
+ def _token(text: str, i: int) -> int:
92
+ while i < len(text) and text[i] in TCHAR:
93
+ i += 1
94
+ return i
95
+
96
+
97
+ def _directive(text: str, i: int) -> tuple[Directive, int]:
98
+ start, end = i, _token(text, i)
99
+ if end == i:
100
+ raise _Broken(f"{_show(text[i])} cannot start a directive name", i)
101
+ name, i = text[i:end], _skip_ows(text, end)
102
+ try:
103
+ return _rest(text, i, name, start)
104
+ except _Broken as broken:
105
+ broken.name = name
106
+ raise
107
+
108
+
109
+ def _rest(text: str, i: int, name: str, start: int) -> tuple[Directive, int]:
110
+ value: str | None = None
111
+ quoted = spaced = False
112
+ value_offset = i
113
+ if i < len(text) and text[i] == "=":
114
+ i = value_offset = _skip_ows(text, i + 1)
115
+ spaced = text[start + len(name)] != "=" or text[i - 1] != "="
116
+ if i < len(text) and text[i] == '"':
117
+ value, i = _quoted(text, i)
118
+ quoted = True
119
+ else:
120
+ end = _token(text, i)
121
+ if end == i:
122
+ found = "the end" if i == len(text) else _show(text[i])
123
+ raise _Broken(f"expected a value for {name}, found {found}", i)
124
+ value, i = text[i:end], end
125
+ i = _skip_ows(text, i)
126
+ if i < len(text) and text[i] != ";":
127
+ raise _Broken(f"unexpected {_show(text[i])} after {name}; directives end at ';'", i)
128
+ key = ascii_lower(name)
129
+ return Directive(name, key, value, quoted, start, value_offset, spaced), i
130
+
131
+
132
+ def _resync(text: str, i: int) -> int:
133
+ """Index of the next ';' outside a quoted string, or the end of the text."""
134
+ while i < len(text) and text[i] != ";":
135
+ if text[i] == '"':
136
+ try:
137
+ i = _quoted(text, i)[1]
138
+ continue
139
+ except _Broken:
140
+ # A quote that never closes would swallow everything after it; give up on
141
+ # it and resume at the next plain semicolon instead.
142
+ nxt = text.find(";", i)
143
+ return len(text) if nxt < 0 else nxt
144
+ i += 1
145
+ return i
146
+
147
+
148
+ def scan(text: str) -> Scan:
149
+ """Split ``text`` into directives, recording every syntax problem as an :class:`Issue`."""
150
+ result = Scan()
151
+ for fold in _FOLD.finditer(text):
152
+ message = "folded line (obsolete line folding), read as whitespace"
153
+ result.issues.append(Issue("obs-fold", message, "warning", fold.start()))
154
+ text = text[: fold.start()] + " " * len(fold.group()) + text[fold.end() :]
155
+ for index, char in enumerate(text):
156
+ if char in "\r\n\0":
157
+ message = f"{_show(char)} is not allowed in a field value"
158
+ result.issues.append(Issue("control-character", message, "error", index))
159
+ text = text.translate(_REPLACED)
160
+ if not text.strip(OWS):
161
+ return result
162
+ i = 0
163
+ while True:
164
+ start = i = _skip_ows(text, i)
165
+ if i == len(text) or text[i] == ";":
166
+ # Point at a semicolon: the one closing this empty element, or the last one.
167
+ where = i if i < len(text) else text.rindex(";")
168
+ message = "empty directive (an extra ';')"
169
+ result.issues.append(Issue("empty-directive", message, "warning", where))
170
+ else:
171
+ try:
172
+ directive, i = _directive(text, i)
173
+ result.directives.append(directive)
174
+ except _Broken as broken:
175
+ result.issues.append(Issue("syntax", broken.message, "error", broken.offset))
176
+ result.broken.append((start, ascii_lower(broken.name)))
177
+ i = _resync(text, start)
178
+ if i >= len(text):
179
+ return result
180
+ i += 1
hstsheader/py.typed ADDED
File without changes
@@ -0,0 +1,241 @@
1
+ Metadata-Version: 2.5
2
+ Name: hstsheader
3
+ Version: 0.2.0
4
+ Summary: Parse, check and build the HTTP Strict-Transport-Security header, typed and zero-dependency.
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/hstsheader
7
+ Project-URL: Issues, https://github.com/JaviLianes8/hstsheader/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/hstsheader/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: headers,hsts,http,parser,preload,rfc6797,security,strict-transport-security
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Topic :: Security
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Provides-Extra: dev
30
+ Requires-Dist: build; extra == 'dev'
31
+ Requires-Dist: mypy; extra == 'dev'
32
+ Requires-Dist: pytest; extra == 'dev'
33
+ Requires-Dist: ruff; extra == 'dev'
34
+ Requires-Dist: twine; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # hstsheader
38
+
39
+ Parse, check and build the HTTP Strict-Transport-Security header, typed and zero-dependency.
40
+
41
+ - Reads the header the way RFC 6797 says a user agent must, or tolerantly for logs and
42
+ scanners.
43
+ - Lists every problem with a stable code and the offset where it starts.
44
+ - Checks the hstspreload.org header requirements before you submit a domain.
45
+ - Builds header values that cannot carry an injected line.
46
+ - Ships a command line that checks the output of `curl -sI`.
47
+
48
+ ## Install
49
+
50
+ ```
51
+ pip install hstsheader
52
+ ```
53
+
54
+ Python 3.10 or newer, no dependencies.
55
+
56
+ ## Read a header
57
+
58
+ ```python
59
+ from hstsheader import parse
60
+
61
+ policy = parse("max-age=31536000; includeSubDomains; preload")
62
+ print(policy)
63
+ # Hsts(max_age=31536000, include_subdomains=True, preload=True)
64
+ print(parse(' MAX-AGE = "600" ; unknown=1'))
65
+ # Hsts(max_age=600, include_subdomains=False, preload=False)
66
+ ```
67
+
68
+ Directive names match without regard to ASCII case, whitespace may surround `;` and `=`,
69
+ unknown directives are ignored and `max-age` may be quoted, as RFC 6797 section 6.1
70
+ allows. A folded line counts as whitespace. Inside a quoted string, a backslash may be
71
+ followed by HTAB, SP, a visible ASCII character or obs-text (RFC 9110 section 5.6.4,
72
+ stricter on purpose than the RFC 2616 rule, which allowed any ASCII character there).
73
+ `max-age` must be ASCII digits; a value above 2147483648 (2^31, the ceiling RFC
74
+ 9111 section 1.2.2 sets for delta-seconds) is read as 2147483648.
75
+
76
+ ## Strict or tolerant
77
+
78
+ By default `parse` is tolerant: it skips directives it cannot read, keeps the first valid
79
+ `max-age`, and raises `HstsError` only when no valid `max-age` is left. With
80
+ `strict=True` it accepts only a header that a user agent following RFC 6797 honours, and
81
+ the error names the rule and the offset.
82
+
83
+ ```python
84
+ from hstsheader import HstsError, parse
85
+
86
+ header = "max-age=600; max-age=5; includeSubDomains"
87
+ print(parse(header).max_age)
88
+ # 600
89
+ try:
90
+ parse(header, strict=True)
91
+ except HstsError as error:
92
+ print(error.code, error.offset)
93
+ # duplicate 13
94
+ ```
95
+
96
+ Strict mode refuses a missing or non-numeric `max-age`, a directive that appears twice, a
97
+ value on `includeSubDomains` or `preload`, anything outside the token and quoted-string
98
+ grammar, and CR, LF or NUL. Both modes take the field lines of a response as a list and
99
+ read only the first one, as RFC 6797 section 8.1 requires.
100
+
101
+ ## Lint a header
102
+
103
+ ```python
104
+ from hstsheader import lint
105
+
106
+ for issue in lint('max-age="031536000"; includeSubDomains=yes;'):
107
+ print(issue.severity, issue.code, issue.offset)
108
+ # warning max-age.leading-zero 8
109
+ # warning max-age.quoted 8
110
+ # error value-not-allowed 21
111
+ # warning empty-directive 42
112
+ ```
113
+
114
+ An `error` means a user agent that follows RFC 6797 ignores the whole header. A
115
+ `warning` marks something legal that is likely a mistake or that some checkers refuse.
116
+ Offsets index the field value; an empty directive points at its semicolon.
117
+
118
+ | Code | Severity | Meaning |
119
+ | --- | --- | --- |
120
+ | `syntax` | error | outside the directive grammar (offset of the first bad character) |
121
+ | `control-character` | error | CR, LF or NUL in the value |
122
+ | `max-age.missing` | error | no `max-age` directive |
123
+ | `max-age.no-value` | error | `max-age` without `=` |
124
+ | `max-age.not-digits` | error | the value is not ASCII digits |
125
+ | `duplicate` | error | a directive appears more than once |
126
+ | `value-not-allowed` | error | `includeSubDomains` or `preload` with a value |
127
+ | `field.missing` | error | an empty list of field lines |
128
+ | `max-age.leading-zero` | warning | the value starts with 0 |
129
+ | `max-age.capped` | warning | the value is above 2147483648 and is read as that |
130
+ | `max-age.quoted` | warning | valid, but hstspreload.org refuses it |
131
+ | `max-age.spaced` | warning | space around `=` in max-age: valid, but hstspreload.org refuses it |
132
+ | `obs-fold` | warning | a folded line, read as whitespace |
133
+ | `max-age.zero-subdomains` | warning | `max-age=0` makes `includeSubDomains` pointless |
134
+ | `empty-directive` | warning | an extra `;` |
135
+ | `unknown-directive` | warning | a directive this library does not know is ignored |
136
+ | `multiple-fields` | warning | more than one field line; only the first counts |
137
+
138
+ ## Check preload eligibility
139
+
140
+ ```python
141
+ from hstsheader import is_preloadable, preload_issues
142
+
143
+ print(is_preloadable("max-age=63072000; includeSubDomains; preload"))
144
+ # True
145
+ for issue in preload_issues("max-age=10886400; preload"):
146
+ print(issue.code)
147
+ # preload.subdomains-missing
148
+ # preload.max-age-too-short
149
+ ```
150
+
151
+ The rules are the header requirements on hstspreload.org: `includeSubDomains` and
152
+ `preload` present and a `max-age` of at least 31536000 seconds. The site's checker also
153
+ refuses a quoted `max-age`, space around its `=` and a value above 2^64 - 1, treats
154
+ `max-age=0` as a removal request and warns above ten years; the same findings come back
155
+ as `preload.max-age-quoted`, `preload.max-age-spaced`, `preload.max-age-unreadable`,
156
+ `preload.max-age-zero` and the warning `preload.max-age-over-ten-years`. RFC errors are
157
+ included, except a repeated directive other than `max-age` and `includeSubDomains`, which
158
+ the site only warns about and which comes back as a warning. The site also checks redirects, certificates and
159
+ subdomains over the network; that part is out of scope here.
160
+
161
+ ## Build a header
162
+
163
+ ```python
164
+ from hstsheader import Hsts, build
165
+
166
+ print(build(31536000, include_subdomains=True))
167
+ # max-age=31536000; includeSubDomains
168
+ print(build(Hsts(600, preload=True), extensions={"report-uri": "https://example.com/r;1"}))
169
+ # max-age=600; preload; report-uri="https://example.com/r;1"
170
+ ```
171
+
172
+ Flags must be `bool`, `max_age` an `int` from 0 to 2147483648. Extension names must be
173
+ HTTP tokens and cannot repeat or shadow the three known directives; values that are not
174
+ tokens are quoted, and control or non-ASCII characters raise `HstsError`, so a value
175
+ cannot smuggle a second header line. For every valid policy,
176
+ `parse(build(policy), strict=True) == policy`.
177
+
178
+ ## Command line
179
+
180
+ ```
181
+ $ hstsheader "max-age=600; includeSubDomains"
182
+ $ curl -sI https://example.com | hstsheader --preload
183
+ $ python -m hstsheader "max-age=600"
184
+ ```
185
+
186
+ It prints the policy and the findings as JSON. The exit status is 0 when a user agent
187
+ following RFC 6797 honours the header (with `--preload`: when it meets the preload
188
+ rules), 1 when it does not, and 2 when the input has no Strict-Transport-Security field
189
+ (empty input included). A head ends at its blank line, folded lines are joined, pasted
190
+ fields without a status line are accepted, and with several responses (`curl -sIL`) the
191
+ last one is checked, since that is the one the browser keeps.
192
+
193
+ ## Where Chromium differs
194
+
195
+ This library follows RFC 6797. Chromium's parser is more lenient in two ways and stricter
196
+ in one: it accepts a repeated directive other than `max-age` and `includeSubDomains`
197
+ (so a repeated `preload` still works there), it does not treat `preload` as special, and
198
+ it stores at most one year of `max-age` whatever the header says.
199
+
200
+ ## API
201
+
202
+ | Name | What it is |
203
+ | --- | --- |
204
+ | `parse(header, *, strict=False)` | read a value or a list of field lines into an `Hsts` |
205
+ | `lint(header)` | every finding as a tuple of `Issue` |
206
+ | `preload_issues(header)` | findings against the hstspreload.org rules (value or `Hsts`) |
207
+ | `is_preloadable(header)` | `True` when `preload_issues` has no error |
208
+ | `build(max_age, *, include_subdomains, preload, extensions)` | write a header value |
209
+ | `Hsts` | frozen dataclass: `max_age`, `include_subdomains`, `preload`, checked on creation |
210
+ | `Issue` | frozen dataclass: `code`, `message`, `severity`, `offset` |
211
+ | `HstsError` | a `ValueError` with `code` and `offset` |
212
+ | `DELTA_SECONDS_MAX` | 2147483648, the ceiling for `max-age` |
213
+ | `PRELOAD_MIN_MAX_AGE` | 31536000, the shortest `max-age` the preload list takes |
214
+
215
+ ## Sources
216
+
217
+ RFC 6797 (the header and the browser processing model), RFC 2616 (the grammar it builds
218
+ on), RFC 9110 (token, quoted-string, field values), RFC 9111 (delta-seconds), the
219
+ hstspreload.org requirements and its header checker, and Chromium's header parser. The
220
+ tests check the library against copies of these files; `python scripts/fetch_upstream.py`
221
+ fetches them and `tests/upstream/MANIFEST.json` pins each one by URL, revision and sha256.
222
+ Those tests skip until the files are fetched.
223
+
224
+ ## Why
225
+
226
+ RFC 6797 defines a tiny header, yet Python has no standalone, typed, zero-dependency
227
+ codec for it. The existing options are all tied to something larger: `apifrom` buries
228
+ the logic in a framework utility, `pyramid_hsts` only writes the header, and
229
+ PyGObject's `Soup.HSTSPolicy` pulls in the libsoup and GObject stack. This library is a
230
+ single-purpose reader, checker and writer with full type hints and nothing to install
231
+ alongside it.
232
+
233
+ ## License
234
+
235
+ MIT. See [LICENSE](LICENSE).
236
+
237
+ ## Links
238
+
239
+ - Website: https://lianes8studio.com
240
+ - GitHub: https://github.com/JaviLianes8
241
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,17 @@
1
+ hstsheader/__init__.py,sha256=HqzzFBjlIqTeW-ES21kEnw386B8JJBH46cTFSk085ls,562
2
+ hstsheader/__main__.py,sha256=5LX8XY5PKQ3VeZZc5B1WtmsTbJlRtdJRE6wSiFr_71Y,128
3
+ hstsheader/_build.py,sha256=67sJ7f01LueQGcxv1TRVMh6xdiocMP4v43iDiKXCdYE,3212
4
+ hstsheader/_chars.py,sha256=BoUp1f4w72cKDFYWVgme7dzulKiEBsrlRklpZPlFF7A,1512
5
+ hstsheader/_cli.py,sha256=DKDJRHOyGVc1EIuZ08ECu3X6WMPOOBTlMIV_YowahZc,3050
6
+ hstsheader/_head.py,sha256=unLwjGSyfW9hSV6M2iLIkpNGAb6vpwg-52zH6tM9ih8,2094
7
+ hstsheader/_lint.py,sha256=PGIF7br11PIm9owHLfLVffmroO9QUK95CBaf00NXOns,7290
8
+ hstsheader/_model.py,sha256=zQNBoUuFnG0ZMr4_VeF8HbF-_VHzLbCnv2AIJ-JGdk0,2793
9
+ hstsheader/_parse.py,sha256=jddOmywRhh-miRob0xF99uttQiuZvScQgz71OMooGKc,1732
10
+ hstsheader/_preload.py,sha256=ibdbrfPlByQ6G2JOVDvtMJLGOo7rhTcOjX6rFMYsw6g,3547
11
+ hstsheader/_scan.py,sha256=JF6FJlGfhB5dhxOn0ZD4jvS7ylzMpEh6b0Q3T602-3c,6693
12
+ hstsheader/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ hstsheader-0.2.0.dist-info/METADATA,sha256=jS31jUbpuRc44dfmyK-MwwseJoMsEz5_fft96PMJNS4,10480
14
+ hstsheader-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
15
+ hstsheader-0.2.0.dist-info/entry_points.txt,sha256=aFYACtPg_V9h7d05UkP-zqYns4b9yIW65LHilkJjK3c,52
16
+ hstsheader-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
17
+ hstsheader-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hstsheader = hstsheader._cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.