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.
- hstsheader-0.2.0/.gitattributes +1 -0
- hstsheader-0.2.0/.gitignore +16 -0
- hstsheader-0.2.0/CHANGELOG.md +44 -0
- hstsheader-0.2.0/CONTRIBUTING.md +29 -0
- hstsheader-0.2.0/LICENSE +21 -0
- hstsheader-0.2.0/PKG-INFO +241 -0
- hstsheader-0.2.0/README.md +205 -0
- hstsheader-0.2.0/pyproject.toml +75 -0
- hstsheader-0.2.0/requirements-dev.txt +3 -0
- hstsheader-0.2.0/scripts/check.ps1 +16 -0
- hstsheader-0.2.0/scripts/fetch_upstream.py +41 -0
- hstsheader-0.2.0/scripts/release.ps1 +28 -0
- hstsheader-0.2.0/src/hstsheader/__init__.py +24 -0
- hstsheader-0.2.0/src/hstsheader/__main__.py +9 -0
- hstsheader-0.2.0/src/hstsheader/_build.py +78 -0
- hstsheader-0.2.0/src/hstsheader/_chars.py +49 -0
- hstsheader-0.2.0/src/hstsheader/_cli.py +79 -0
- hstsheader-0.2.0/src/hstsheader/_head.py +59 -0
- hstsheader-0.2.0/src/hstsheader/_lint.py +166 -0
- hstsheader-0.2.0/src/hstsheader/_model.py +79 -0
- hstsheader-0.2.0/src/hstsheader/_parse.py +36 -0
- hstsheader-0.2.0/src/hstsheader/_preload.py +74 -0
- hstsheader-0.2.0/src/hstsheader/_scan.py +180 -0
- hstsheader-0.2.0/src/hstsheader/py.typed +0 -0
- hstsheader-0.2.0/tests/conftest.py +78 -0
- hstsheader-0.2.0/tests/test_build.py +113 -0
- hstsheader-0.2.0/tests/test_cli.py +117 -0
- hstsheader-0.2.0/tests/test_contract.py +51 -0
- hstsheader-0.2.0/tests/test_docs.py +94 -0
- hstsheader-0.2.0/tests/test_e2e_wheel.py +122 -0
- hstsheader-0.2.0/tests/test_lint.py +84 -0
- hstsheader-0.2.0/tests/test_model.py +56 -0
- hstsheader-0.2.0/tests/test_parse.py +166 -0
- hstsheader-0.2.0/tests/test_preload.py +72 -0
- hstsheader-0.2.0/tests/test_review_fixes.py +171 -0
- hstsheader-0.2.0/tests/test_roundtrip.py +72 -0
- hstsheader-0.2.0/tests/test_sources.py +170 -0
- hstsheader-0.2.0/tests/test_strict.py +72 -0
- 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.
|
hstsheader-0.2.0/LICENSE
ADDED
|
@@ -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,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"
|