hstsheader 0.2.0__tar.gz

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.
Files changed (39) hide show
  1. hstsheader-0.2.0/.gitattributes +1 -0
  2. hstsheader-0.2.0/.gitignore +16 -0
  3. hstsheader-0.2.0/CHANGELOG.md +44 -0
  4. hstsheader-0.2.0/CONTRIBUTING.md +29 -0
  5. hstsheader-0.2.0/LICENSE +21 -0
  6. hstsheader-0.2.0/PKG-INFO +241 -0
  7. hstsheader-0.2.0/README.md +205 -0
  8. hstsheader-0.2.0/pyproject.toml +75 -0
  9. hstsheader-0.2.0/requirements-dev.txt +3 -0
  10. hstsheader-0.2.0/scripts/check.ps1 +16 -0
  11. hstsheader-0.2.0/scripts/fetch_upstream.py +41 -0
  12. hstsheader-0.2.0/scripts/release.ps1 +28 -0
  13. hstsheader-0.2.0/src/hstsheader/__init__.py +24 -0
  14. hstsheader-0.2.0/src/hstsheader/__main__.py +9 -0
  15. hstsheader-0.2.0/src/hstsheader/_build.py +78 -0
  16. hstsheader-0.2.0/src/hstsheader/_chars.py +49 -0
  17. hstsheader-0.2.0/src/hstsheader/_cli.py +79 -0
  18. hstsheader-0.2.0/src/hstsheader/_head.py +59 -0
  19. hstsheader-0.2.0/src/hstsheader/_lint.py +166 -0
  20. hstsheader-0.2.0/src/hstsheader/_model.py +79 -0
  21. hstsheader-0.2.0/src/hstsheader/_parse.py +36 -0
  22. hstsheader-0.2.0/src/hstsheader/_preload.py +74 -0
  23. hstsheader-0.2.0/src/hstsheader/_scan.py +180 -0
  24. hstsheader-0.2.0/src/hstsheader/py.typed +0 -0
  25. hstsheader-0.2.0/tests/conftest.py +78 -0
  26. hstsheader-0.2.0/tests/test_build.py +113 -0
  27. hstsheader-0.2.0/tests/test_cli.py +117 -0
  28. hstsheader-0.2.0/tests/test_contract.py +51 -0
  29. hstsheader-0.2.0/tests/test_docs.py +94 -0
  30. hstsheader-0.2.0/tests/test_e2e_wheel.py +122 -0
  31. hstsheader-0.2.0/tests/test_lint.py +84 -0
  32. hstsheader-0.2.0/tests/test_model.py +56 -0
  33. hstsheader-0.2.0/tests/test_parse.py +166 -0
  34. hstsheader-0.2.0/tests/test_preload.py +72 -0
  35. hstsheader-0.2.0/tests/test_review_fixes.py +171 -0
  36. hstsheader-0.2.0/tests/test_roundtrip.py +72 -0
  37. hstsheader-0.2.0/tests/test_sources.py +170 -0
  38. hstsheader-0.2.0/tests/test_strict.py +72 -0
  39. hstsheader-0.2.0/tests/upstream/MANIFEST.json +85 -0
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ build/
6
+ dist/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+
14
+ # Third-party reference files, fetched locally by scripts/fetch_upstream.py; only the manifest is tracked.
15
+ tests/upstream/*
16
+ !tests/upstream/MANIFEST.json
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - not released yet
10
+
11
+ ### Added
12
+
13
+ - `parse(header, strict=True)` accepts only a header that a conforming browser honours
14
+ (RFC 6797 sections 6.1 and 8.1) and raises `HstsError` with a `code` and an `offset`.
15
+ - `lint` lists every finding as an `Issue` with a code, a severity and an offset.
16
+ - `preload_issues` and `is_preloadable` check the hstspreload.org header requirements.
17
+ - `build` takes an `Hsts` object and an `extensions` mapping for further directives.
18
+ - `parse`, `lint` and the preload checks accept the field lines of a response and read the
19
+ first one only.
20
+ - The `hstsheader` command line (also `python -m hstsheader`) checks a value or the output
21
+ of `curl -sI` and prints JSON.
22
+ - `DELTA_SECONDS_MAX` and `PRELOAD_MIN_MAX_AGE` constants.
23
+ - Folded lines (obsolete line folding) are read as whitespace, with a warning.
24
+
25
+ ### Changed
26
+
27
+ - The reader follows the directive grammar: token names, token or quoted-string values,
28
+ whitespace around `;` and `=`. A broken directive is skipped up to the next `;` instead
29
+ of failing the whole header in tolerant mode.
30
+ - The first `max-age` wins when it repeats (it used to be the last one); strict mode
31
+ refuses any repeated directive.
32
+ - `includeSubDomains` or `preload` with a value is no longer read as asserted.
33
+ - `max-age` takes ASCII digits only (fullwidth and other Unicode digits used to pass) and
34
+ values above 2147483648 are read as 2147483648 instead of failing on very long input.
35
+ - A quoted value is unescaped per RFC 9110: any quoted-pair stands for its octet.
36
+ - `Hsts` checks its fields on creation, and `build` refuses non-bool flags and a `max_age`
37
+ above 2147483648.
38
+ - `bytes` input raises a `TypeError` that says to decode it first.
39
+
40
+ ## [0.1.0]
41
+
42
+ - Add `parse` to read a Strict-Transport-Security header into a typed `Hsts` object.
43
+ - Add `build` to render a header value from a max-age and optional flags.
44
+ - Add the `Hsts` dataclass and the `HstsError` exception.
@@ -0,0 +1,29 @@
1
+ # Contributing
2
+
3
+ Thanks for taking the time. This project keeps a small, predictable shape.
4
+
5
+ ## Setup
6
+
7
+ ```
8
+ pip install -r requirements-dev.txt
9
+ ```
10
+
11
+ ## Reference files
12
+
13
+ Some tests check the library against the RFCs, hstspreload.org and Chromium's parser.
14
+ Those files are not in the repository; fetch them once with
15
+ `python scripts/fetch_upstream.py` (each is checked against `tests/upstream/MANIFEST.json`).
16
+ Without them the tests that need them are skipped.
17
+
18
+ The installed-wheel test builds and installs the package; run it with
19
+ `HSTSHEADER_E2E=1 pytest tests/test_e2e_wheel.py`.
20
+
21
+ ## Before you open a pull request
22
+
23
+ - Keep public API in `src/hstsheader/__init__.py`; internals live in `_`-prefixed modules.
24
+ - Add a test for the behaviour you change. Tests inject time, disk and network; they
25
+ never touch the real thing.
26
+ - Run `scripts/check.ps1` (format, lint, types, tests) and make sure it passes.
27
+ - One logical change per commit, imperative subject line.
28
+
29
+ Open an issue first if the change is large or shifts the public API.
@@ -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.
@@ -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,205 @@
1
+ # hstsheader
2
+
3
+ Parse, check and build the HTTP Strict-Transport-Security header, typed and zero-dependency.
4
+
5
+ - Reads the header the way RFC 6797 says a user agent must, or tolerantly for logs and
6
+ scanners.
7
+ - Lists every problem with a stable code and the offset where it starts.
8
+ - Checks the hstspreload.org header requirements before you submit a domain.
9
+ - Builds header values that cannot carry an injected line.
10
+ - Ships a command line that checks the output of `curl -sI`.
11
+
12
+ ## Install
13
+
14
+ ```
15
+ pip install hstsheader
16
+ ```
17
+
18
+ Python 3.10 or newer, no dependencies.
19
+
20
+ ## Read a header
21
+
22
+ ```python
23
+ from hstsheader import parse
24
+
25
+ policy = parse("max-age=31536000; includeSubDomains; preload")
26
+ print(policy)
27
+ # Hsts(max_age=31536000, include_subdomains=True, preload=True)
28
+ print(parse(' MAX-AGE = "600" ; unknown=1'))
29
+ # Hsts(max_age=600, include_subdomains=False, preload=False)
30
+ ```
31
+
32
+ Directive names match without regard to ASCII case, whitespace may surround `;` and `=`,
33
+ unknown directives are ignored and `max-age` may be quoted, as RFC 6797 section 6.1
34
+ allows. A folded line counts as whitespace. Inside a quoted string, a backslash may be
35
+ followed by HTAB, SP, a visible ASCII character or obs-text (RFC 9110 section 5.6.4,
36
+ stricter on purpose than the RFC 2616 rule, which allowed any ASCII character there).
37
+ `max-age` must be ASCII digits; a value above 2147483648 (2^31, the ceiling RFC
38
+ 9111 section 1.2.2 sets for delta-seconds) is read as 2147483648.
39
+
40
+ ## Strict or tolerant
41
+
42
+ By default `parse` is tolerant: it skips directives it cannot read, keeps the first valid
43
+ `max-age`, and raises `HstsError` only when no valid `max-age` is left. With
44
+ `strict=True` it accepts only a header that a user agent following RFC 6797 honours, and
45
+ the error names the rule and the offset.
46
+
47
+ ```python
48
+ from hstsheader import HstsError, parse
49
+
50
+ header = "max-age=600; max-age=5; includeSubDomains"
51
+ print(parse(header).max_age)
52
+ # 600
53
+ try:
54
+ parse(header, strict=True)
55
+ except HstsError as error:
56
+ print(error.code, error.offset)
57
+ # duplicate 13
58
+ ```
59
+
60
+ Strict mode refuses a missing or non-numeric `max-age`, a directive that appears twice, a
61
+ value on `includeSubDomains` or `preload`, anything outside the token and quoted-string
62
+ grammar, and CR, LF or NUL. Both modes take the field lines of a response as a list and
63
+ read only the first one, as RFC 6797 section 8.1 requires.
64
+
65
+ ## Lint a header
66
+
67
+ ```python
68
+ from hstsheader import lint
69
+
70
+ for issue in lint('max-age="031536000"; includeSubDomains=yes;'):
71
+ print(issue.severity, issue.code, issue.offset)
72
+ # warning max-age.leading-zero 8
73
+ # warning max-age.quoted 8
74
+ # error value-not-allowed 21
75
+ # warning empty-directive 42
76
+ ```
77
+
78
+ An `error` means a user agent that follows RFC 6797 ignores the whole header. A
79
+ `warning` marks something legal that is likely a mistake or that some checkers refuse.
80
+ Offsets index the field value; an empty directive points at its semicolon.
81
+
82
+ | Code | Severity | Meaning |
83
+ | --- | --- | --- |
84
+ | `syntax` | error | outside the directive grammar (offset of the first bad character) |
85
+ | `control-character` | error | CR, LF or NUL in the value |
86
+ | `max-age.missing` | error | no `max-age` directive |
87
+ | `max-age.no-value` | error | `max-age` without `=` |
88
+ | `max-age.not-digits` | error | the value is not ASCII digits |
89
+ | `duplicate` | error | a directive appears more than once |
90
+ | `value-not-allowed` | error | `includeSubDomains` or `preload` with a value |
91
+ | `field.missing` | error | an empty list of field lines |
92
+ | `max-age.leading-zero` | warning | the value starts with 0 |
93
+ | `max-age.capped` | warning | the value is above 2147483648 and is read as that |
94
+ | `max-age.quoted` | warning | valid, but hstspreload.org refuses it |
95
+ | `max-age.spaced` | warning | space around `=` in max-age: valid, but hstspreload.org refuses it |
96
+ | `obs-fold` | warning | a folded line, read as whitespace |
97
+ | `max-age.zero-subdomains` | warning | `max-age=0` makes `includeSubDomains` pointless |
98
+ | `empty-directive` | warning | an extra `;` |
99
+ | `unknown-directive` | warning | a directive this library does not know is ignored |
100
+ | `multiple-fields` | warning | more than one field line; only the first counts |
101
+
102
+ ## Check preload eligibility
103
+
104
+ ```python
105
+ from hstsheader import is_preloadable, preload_issues
106
+
107
+ print(is_preloadable("max-age=63072000; includeSubDomains; preload"))
108
+ # True
109
+ for issue in preload_issues("max-age=10886400; preload"):
110
+ print(issue.code)
111
+ # preload.subdomains-missing
112
+ # preload.max-age-too-short
113
+ ```
114
+
115
+ The rules are the header requirements on hstspreload.org: `includeSubDomains` and
116
+ `preload` present and a `max-age` of at least 31536000 seconds. The site's checker also
117
+ refuses a quoted `max-age`, space around its `=` and a value above 2^64 - 1, treats
118
+ `max-age=0` as a removal request and warns above ten years; the same findings come back
119
+ as `preload.max-age-quoted`, `preload.max-age-spaced`, `preload.max-age-unreadable`,
120
+ `preload.max-age-zero` and the warning `preload.max-age-over-ten-years`. RFC errors are
121
+ included, except a repeated directive other than `max-age` and `includeSubDomains`, which
122
+ the site only warns about and which comes back as a warning. The site also checks redirects, certificates and
123
+ subdomains over the network; that part is out of scope here.
124
+
125
+ ## Build a header
126
+
127
+ ```python
128
+ from hstsheader import Hsts, build
129
+
130
+ print(build(31536000, include_subdomains=True))
131
+ # max-age=31536000; includeSubDomains
132
+ print(build(Hsts(600, preload=True), extensions={"report-uri": "https://example.com/r;1"}))
133
+ # max-age=600; preload; report-uri="https://example.com/r;1"
134
+ ```
135
+
136
+ Flags must be `bool`, `max_age` an `int` from 0 to 2147483648. Extension names must be
137
+ HTTP tokens and cannot repeat or shadow the three known directives; values that are not
138
+ tokens are quoted, and control or non-ASCII characters raise `HstsError`, so a value
139
+ cannot smuggle a second header line. For every valid policy,
140
+ `parse(build(policy), strict=True) == policy`.
141
+
142
+ ## Command line
143
+
144
+ ```
145
+ $ hstsheader "max-age=600; includeSubDomains"
146
+ $ curl -sI https://example.com | hstsheader --preload
147
+ $ python -m hstsheader "max-age=600"
148
+ ```
149
+
150
+ It prints the policy and the findings as JSON. The exit status is 0 when a user agent
151
+ following RFC 6797 honours the header (with `--preload`: when it meets the preload
152
+ rules), 1 when it does not, and 2 when the input has no Strict-Transport-Security field
153
+ (empty input included). A head ends at its blank line, folded lines are joined, pasted
154
+ fields without a status line are accepted, and with several responses (`curl -sIL`) the
155
+ last one is checked, since that is the one the browser keeps.
156
+
157
+ ## Where Chromium differs
158
+
159
+ This library follows RFC 6797. Chromium's parser is more lenient in two ways and stricter
160
+ in one: it accepts a repeated directive other than `max-age` and `includeSubDomains`
161
+ (so a repeated `preload` still works there), it does not treat `preload` as special, and
162
+ it stores at most one year of `max-age` whatever the header says.
163
+
164
+ ## API
165
+
166
+ | Name | What it is |
167
+ | --- | --- |
168
+ | `parse(header, *, strict=False)` | read a value or a list of field lines into an `Hsts` |
169
+ | `lint(header)` | every finding as a tuple of `Issue` |
170
+ | `preload_issues(header)` | findings against the hstspreload.org rules (value or `Hsts`) |
171
+ | `is_preloadable(header)` | `True` when `preload_issues` has no error |
172
+ | `build(max_age, *, include_subdomains, preload, extensions)` | write a header value |
173
+ | `Hsts` | frozen dataclass: `max_age`, `include_subdomains`, `preload`, checked on creation |
174
+ | `Issue` | frozen dataclass: `code`, `message`, `severity`, `offset` |
175
+ | `HstsError` | a `ValueError` with `code` and `offset` |
176
+ | `DELTA_SECONDS_MAX` | 2147483648, the ceiling for `max-age` |
177
+ | `PRELOAD_MIN_MAX_AGE` | 31536000, the shortest `max-age` the preload list takes |
178
+
179
+ ## Sources
180
+
181
+ RFC 6797 (the header and the browser processing model), RFC 2616 (the grammar it builds
182
+ on), RFC 9110 (token, quoted-string, field values), RFC 9111 (delta-seconds), the
183
+ hstspreload.org requirements and its header checker, and Chromium's header parser. The
184
+ tests check the library against copies of these files; `python scripts/fetch_upstream.py`
185
+ fetches them and `tests/upstream/MANIFEST.json` pins each one by URL, revision and sha256.
186
+ Those tests skip until the files are fetched.
187
+
188
+ ## Why
189
+
190
+ RFC 6797 defines a tiny header, yet Python has no standalone, typed, zero-dependency
191
+ codec for it. The existing options are all tied to something larger: `apifrom` buries
192
+ the logic in a framework utility, `pyramid_hsts` only writes the header, and
193
+ PyGObject's `Soup.HSTSPolicy` pulls in the libsoup and GObject stack. This library is a
194
+ single-purpose reader, checker and writer with full type hints and nothing to install
195
+ alongside it.
196
+
197
+ ## License
198
+
199
+ MIT. See [LICENSE](LICENSE).
200
+
201
+ ## Links
202
+
203
+ - Website: https://lianes8studio.com
204
+ - GitHub: https://github.com/JaviLianes8
205
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,75 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hstsheader"
7
+ version = "0.2.0"
8
+ description = "Parse, check and build the HTTP Strict-Transport-Security header, typed and zero-dependency."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Javier Lianes", email = "javier@lianes8studio.com" }]
14
+ keywords = ["hsts", "strict-transport-security", "http", "headers", "rfc6797", "preload", "security", "parser"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Internet :: WWW/HTTP",
27
+ "Topic :: Security",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = []
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["ruff", "mypy", "pytest", "build", "twine"]
34
+
35
+ [project.scripts]
36
+ hstsheader = "hstsheader._cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://lianes8studio.com"
40
+ Repository = "https://github.com/JaviLianes8/hstsheader"
41
+ Issues = "https://github.com/JaviLianes8/hstsheader/issues"
42
+ Changelog = "https://github.com/JaviLianes8/hstsheader/blob/main/CHANGELOG.md"
43
+ LinkedIn = "https://www.linkedin.com/in/jlianes/"
44
+ Funding = "https://buymeacoffee.com/jlianesglrs"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/hstsheader"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ # Third-party reference files are fetched locally and never shipped; only their manifest is.
51
+ exclude = [
52
+ ".github",
53
+ "IMPROVEMENTS-round*.md",
54
+ "tests/upstream/*.txt",
55
+ "tests/upstream/*.html",
56
+ "tests/upstream/*.go",
57
+ "tests/upstream/*.cc",
58
+ "tests/upstream/*.h",
59
+ ]
60
+
61
+ [tool.ruff]
62
+ line-length = 100
63
+ target-version = "py310"
64
+
65
+ [tool.ruff.lint]
66
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
67
+
68
+ [tool.mypy]
69
+ python_version = "3.10"
70
+ strict = true
71
+
72
+ [tool.pytest.ini_options]
73
+ testpaths = ["tests"]
74
+ pythonpath = ["src"]
75
+ addopts = "-q"
@@ -0,0 +1,3 @@
1
+ # Development dependencies: the package in editable mode plus its dev extras
2
+ # (ruff, mypy, pytest, build, twine). Install with: pip install -r requirements-dev.txt
3
+ -e .[dev]
@@ -0,0 +1,16 @@
1
+ # Local quality gate. Run before every commit. No CI runs in the cloud.
2
+ # $ErrorActionPreference does NOT stop on a native command's non-zero exit, so each tool's
3
+ # exit code is checked explicitly: a red gate must fail, never pass silently.
4
+ $ErrorActionPreference = "Stop"
5
+
6
+ function Invoke-Step($label, [scriptblock]$cmd) {
7
+ Write-Host "== $label =="
8
+ & $cmd
9
+ if ($LASTEXITCODE -ne 0) { Write-Host "FAILED: $label (exit $LASTEXITCODE)"; exit $LASTEXITCODE }
10
+ }
11
+
12
+ Invoke-Step "ruff format" { ruff format --check . }
13
+ Invoke-Step "ruff lint" { ruff check . }
14
+ Invoke-Step "mypy" { mypy --strict src tests }
15
+ Invoke-Step "pytest" { pytest }
16
+ Write-Host "gate green"