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 +24 -0
- hstsheader/__main__.py +9 -0
- hstsheader/_build.py +78 -0
- hstsheader/_chars.py +49 -0
- hstsheader/_cli.py +79 -0
- hstsheader/_head.py +59 -0
- hstsheader/_lint.py +166 -0
- hstsheader/_model.py +79 -0
- hstsheader/_parse.py +36 -0
- hstsheader/_preload.py +74 -0
- hstsheader/_scan.py +180 -0
- hstsheader/py.typed +0 -0
- hstsheader-0.2.0.dist-info/METADATA +241 -0
- hstsheader-0.2.0.dist-info/RECORD +17 -0
- hstsheader-0.2.0.dist-info/WHEEL +4 -0
- hstsheader-0.2.0.dist-info/entry_points.txt +2 -0
- hstsheader-0.2.0.dist-info/licenses/LICENSE +21 -0
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
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,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.
|