http-fields 0.1.1__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.
- http_fields/__init__.py +193 -0
- http_fields/accept.py +37 -0
- http_fields/acceptcharset.py +48 -0
- http_fields/acceptencoding.py +42 -0
- http_fields/acceptlanguage.py +47 -0
- http_fields/acceptranges.py +35 -0
- http_fields/accesscontrol.py +197 -0
- http_fields/age.py +34 -0
- http_fields/allow.py +46 -0
- http_fields/altsvc.py +70 -0
- http_fields/authbases.py +79 -0
- http_fields/authenticationinfo.py +19 -0
- http_fields/authorization.py +23 -0
- http_fields/cachecontrol.py +119 -0
- http_fields/cachestatus.py +12 -0
- http_fields/connection.py +35 -0
- http_fields/contentdigest.py +19 -0
- http_fields/contentdisposition.py +66 -0
- http_fields/contentencoding.py +45 -0
- http_fields/contentlanguage.py +38 -0
- http_fields/contentlength.py +34 -0
- http_fields/contentlocation.py +24 -0
- http_fields/contentrange.py +74 -0
- http_fields/contenttype.py +104 -0
- http_fields/cookie.py +38 -0
- http_fields/date.py +17 -0
- http_fields/dateheader.py +32 -0
- http_fields/entitytaglist.py +39 -0
- http_fields/etag.py +46 -0
- http_fields/expect.py +52 -0
- http_fields/expires.py +17 -0
- http_fields/forwarded.py +36 -0
- http_fields/fromheader.py +42 -0
- http_fields/header.py +250 -0
- http_fields/host.py +44 -0
- http_fields/ifmatch.py +24 -0
- http_fields/ifmodifiedsince.py +17 -0
- http_fields/ifnonematch.py +24 -0
- http_fields/ifrange.py +49 -0
- http_fields/ifunmodifiedsince.py +17 -0
- http_fields/lastmodified.py +17 -0
- http_fields/link.py +35 -0
- http_fields/location.py +24 -0
- http_fields/maxforwards.py +32 -0
- http_fields/origin.py +42 -0
- http_fields/parsedobjs.py +62 -0
- http_fields/prefer.py +64 -0
- http_fields/priority.py +55 -0
- http_fields/productlistheader.py +33 -0
- http_fields/proxyauthenticate.py +17 -0
- http_fields/proxyauthenticationinfo.py +17 -0
- http_fields/proxyauthorization.py +17 -0
- http_fields/proxystatus.py +12 -0
- http_fields/py.typed +0 -0
- http_fields/range.py +37 -0
- http_fields/referer.py +24 -0
- http_fields/retryafter.py +45 -0
- http_fields/server.py +18 -0
- http_fields/setcookie.py +419 -0
- http_fields/stricttransportsecurity.py +96 -0
- http_fields/structuredfields.py +356 -0
- http_fields/structuredheaders.py +90 -0
- http_fields/te.py +36 -0
- http_fields/trailer.py +44 -0
- http_fields/upgrade.py +35 -0
- http_fields/uriheader.py +36 -0
- http_fields/useragent.py +18 -0
- http_fields/vary.py +40 -0
- http_fields/via.py +35 -0
- http_fields/visitors/__init__.py +1 -0
- http_fields/visitors/rfc3986.py +195 -0
- http_fields/visitors/rfc6265.py +62 -0
- http_fields/visitors/rfc6266.py +234 -0
- http_fields/visitors/rfc7230.py +80 -0
- http_fields/visitors/rfc7239.py +43 -0
- http_fields/visitors/rfc7240.py +77 -0
- http_fields/visitors/rfc7838.py +77 -0
- http_fields/visitors/rfc8288.py +56 -0
- http_fields/visitors/rfc9110/__init__.py +117 -0
- http_fields/visitors/rfc9110/_base.py +71 -0
- http_fields/visitors/rfc9110/accept.py +135 -0
- http_fields/visitors/rfc9110/acceptcharset.py +26 -0
- http_fields/visitors/rfc9110/acceptencoding.py +76 -0
- http_fields/visitors/rfc9110/acceptlanguage.py +55 -0
- http_fields/visitors/rfc9110/acceptranges.py +13 -0
- http_fields/visitors/rfc9110/allow.py +13 -0
- http_fields/visitors/rfc9110/authenticationinfo.py +19 -0
- http_fields/visitors/rfc9110/authorization.py +77 -0
- http_fields/visitors/rfc9110/authparam.py +40 -0
- http_fields/visitors/rfc9110/authscheme.py +17 -0
- http_fields/visitors/rfc9110/challenge.py +57 -0
- http_fields/visitors/rfc9110/comment.py +99 -0
- http_fields/visitors/rfc9110/connection.py +13 -0
- http_fields/visitors/rfc9110/contentlanguage.py +21 -0
- http_fields/visitors/rfc9110/contentlength.py +9 -0
- http_fields/visitors/rfc9110/contentrange.py +70 -0
- http_fields/visitors/rfc9110/contenttype.py +52 -0
- http_fields/visitors/rfc9110/date.py +12 -0
- http_fields/visitors/rfc9110/entitytag.py +66 -0
- http_fields/visitors/rfc9110/etag.py +12 -0
- http_fields/visitors/rfc9110/field.py +15 -0
- http_fields/visitors/rfc9110/host.py +32 -0
- http_fields/visitors/rfc9110/httpdate.py +167 -0
- http_fields/visitors/rfc9110/ifmatch.py +24 -0
- http_fields/visitors/rfc9110/ifmodifiedsince.py +12 -0
- http_fields/visitors/rfc9110/ifnonematch.py +26 -0
- http_fields/visitors/rfc9110/ifunmodifiedsince.py +12 -0
- http_fields/visitors/rfc9110/lastmodified.py +14 -0
- http_fields/visitors/rfc9110/location.py +9 -0
- http_fields/visitors/rfc9110/parameters.py +124 -0
- http_fields/visitors/rfc9110/product.py +39 -0
- http_fields/visitors/rfc9110/quotedstring.py +66 -0
- http_fields/visitors/rfc9110/range.py +100 -0
- http_fields/visitors/rfc9110/rangeunit.py +17 -0
- http_fields/visitors/rfc9110/retryafter.py +20 -0
- http_fields/visitors/rfc9110/server.py +16 -0
- http_fields/visitors/rfc9110/te.py +83 -0
- http_fields/visitors/rfc9110/token.py +18 -0
- http_fields/visitors/rfc9110/token68.py +18 -0
- http_fields/visitors/rfc9110/type.py +17 -0
- http_fields/visitors/rfc9110/upgrade.py +57 -0
- http_fields/visitors/rfc9110/useragent.py +16 -0
- http_fields/visitors/rfc9110/vary.py +10 -0
- http_fields/visitors/rfc9110/via.py +86 -0
- http_fields/visitors/rfc9110/weight.py +40 -0
- http_fields/visitors/rfc9110/wwwauthenticate.py +21 -0
- http_fields/visitors/rfc9111.py +92 -0
- http_fields/wwwauthenticate.py +24 -0
- http_fields-0.1.1.dist-info/METADATA +88 -0
- http_fields-0.1.1.dist-info/RECORD +132 -0
- http_fields-0.1.1.dist-info/WHEEL +4 -0
- http_fields-0.1.1.dist-info/licenses/LICENSE.txt +16 -0
http_fields/referer.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Referer header class."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from abnf import Rule
|
|
6
|
+
from abnf.grammars import rfc9110
|
|
7
|
+
|
|
8
|
+
from http_fields.parsedobjs import ParsedStr
|
|
9
|
+
from http_fields.uriheader import UriHeader
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RefererUri(ParsedStr):
|
|
13
|
+
"""A Referer URI reference (RFC 9110). Self-validating."""
|
|
14
|
+
|
|
15
|
+
parser = rfc9110.Rule("Referer")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Referer(UriHeader):
|
|
19
|
+
"""Referer header, as defined by RFC 9110."""
|
|
20
|
+
|
|
21
|
+
name: ClassVar[str] = "referer"
|
|
22
|
+
rule: ClassVar[Rule] = rfc9110.Rule("Referer")
|
|
23
|
+
uri_type: ClassVar[type[ParsedStr]] = RefererUri
|
|
24
|
+
uri: RefererUri
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Retry-After header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from abnf import Rule
|
|
8
|
+
from abnf.grammars import rfc9110
|
|
9
|
+
from typing_extensions import Self
|
|
10
|
+
|
|
11
|
+
from http_fields.header import Header
|
|
12
|
+
from http_fields.parsedobjs import NonNegativeInt
|
|
13
|
+
from http_fields.visitors.rfc9110 import RetryAfterVisitor, imf_fixdate
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RetryAfter(Header):
|
|
18
|
+
"""Retry-After header, as defined by RFC 9110. The delay is either a number of seconds
|
|
19
|
+
(a NonNegativeInt) or an HTTP-date."""
|
|
20
|
+
|
|
21
|
+
name: ClassVar[str] = "Retry-After"
|
|
22
|
+
rule: ClassVar[Rule] = rfc9110.Rule("Retry-After")
|
|
23
|
+
visitor: ClassVar[RetryAfterVisitor] = RetryAfterVisitor()
|
|
24
|
+
|
|
25
|
+
delay: NonNegativeInt | datetime
|
|
26
|
+
|
|
27
|
+
def __init__(self, delay: int | datetime) -> None:
|
|
28
|
+
if isinstance(delay, datetime):
|
|
29
|
+
object.__setattr__(self, "delay", delay)
|
|
30
|
+
# Runtime guard against callers that ignore the type annotation; the int check
|
|
31
|
+
# reads as redundant to the type checker but rejects a stray non-int at runtime.
|
|
32
|
+
elif isinstance(delay, int) and not isinstance(delay, bool): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
33
|
+
object.__setattr__(self, "delay", NonNegativeInt(delay))
|
|
34
|
+
else:
|
|
35
|
+
raise TypeError("delay must be an int or datetime.")
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def parse(cls, value: str) -> Self:
|
|
39
|
+
return cls(cls.visitor.visit(cls._node(value)))
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def value(self) -> str:
|
|
43
|
+
if isinstance(self.delay, datetime):
|
|
44
|
+
return imf_fixdate(self.delay)
|
|
45
|
+
return str(self.delay)
|
http_fields/server.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Server header class."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
from abnf import Rule
|
|
6
|
+
from abnf.grammars import rfc9110
|
|
7
|
+
|
|
8
|
+
from http_fields.productlistheader import ProductListHeader
|
|
9
|
+
from http_fields.visitors.rfc9110 import ServerVisitor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Server(ProductListHeader):
|
|
13
|
+
"""Server header, as defined by RFC 9110. ``items`` is a sequence of Product or
|
|
14
|
+
Comment values."""
|
|
15
|
+
|
|
16
|
+
name: ClassVar[str] = "Server"
|
|
17
|
+
rule: ClassVar[Rule] = rfc9110.Rule("Server")
|
|
18
|
+
visitor = ServerVisitor()
|
http_fields/setcookie.py
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""SetCookie header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime, time, timedelta, timezone
|
|
5
|
+
from logging import Logger, getLogger
|
|
6
|
+
from typing import Any, ClassVar, Literal, cast
|
|
7
|
+
|
|
8
|
+
from abnf.grammars import rfc6265
|
|
9
|
+
from abnf.grammars.misc import load_grammar_rules
|
|
10
|
+
from abnf.parser import Node, NodeVisitor, ParseError, Rule
|
|
11
|
+
from typing_extensions import Self
|
|
12
|
+
|
|
13
|
+
from http_fields.header import Header
|
|
14
|
+
from http_fields.visitors.rfc9110 import imf_fixdate
|
|
15
|
+
|
|
16
|
+
_LOGGER = getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Control characters disallowed in a Set-Cookie string: C0 except TAB, plus DEL.
|
|
19
|
+
# Blocking CR/LF/NUL here is what prevents header injection via cookie fields.
|
|
20
|
+
_CONTROL_CHARS = frozenset(chr(c) for c in range(0x20) if c != 0x09) | {"\x7f"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@load_grammar_rules()
|
|
24
|
+
class CookieDate(Rule):
|
|
25
|
+
grammar = [
|
|
26
|
+
"cookie-date = *delimiter date-token-list *delimiter",
|
|
27
|
+
"date-token-list = date-token *( 1*delimiter date-token )",
|
|
28
|
+
"date-token = 1*non-delimiter",
|
|
29
|
+
"delimiter = %x09 / %x20-2F / %x3B-40 / %x5B-60 / %x7B-7E",
|
|
30
|
+
'non-delimiter = %x00-08 / %x0A-1F / DIGIT / ":" / ALPHA / %x7F-FF',
|
|
31
|
+
"non-digit = %x00-2F / %x3A-FF",
|
|
32
|
+
"day-of-month = 1*2DIGIT [ non-digit *OCTET ]",
|
|
33
|
+
'month = ( "jan" / "feb" / "mar" / "apr" / "may" / "jun" / "jul" / "aug" / "sep" / "oct" / "nov" / "dec" ) *OCTET',
|
|
34
|
+
"year = 2*4DIGIT [ non-digit *OCTET ]",
|
|
35
|
+
"time = hms-time [ non-digit *OCTET ]",
|
|
36
|
+
'hms-time = time-field ":" time-field ":" time-field',
|
|
37
|
+
"time-field = 1*2DIGIT",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CookieDateVisitor(NodeVisitor):
|
|
42
|
+
def visit_cookie_date(self, node: Node):
|
|
43
|
+
found_time: time | None = None
|
|
44
|
+
found_day_of_month: int | None = None
|
|
45
|
+
found_month: int | None = None
|
|
46
|
+
found_year: int | None = None
|
|
47
|
+
# The date-token-list child visits to list[str] (visit() returns Any from abnf).
|
|
48
|
+
date_tokens: list[str] = next(filter(None, map(self.visit, node.children)))
|
|
49
|
+
for date_token in date_tokens:
|
|
50
|
+
try:
|
|
51
|
+
node = CookieDate("time").parse_all(date_token)
|
|
52
|
+
except ParseError:
|
|
53
|
+
pass
|
|
54
|
+
else:
|
|
55
|
+
if not found_time:
|
|
56
|
+
found_time = self.visit_time(node)
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
node = CookieDate("day-of-month").parse_all(date_token)
|
|
61
|
+
except ParseError:
|
|
62
|
+
pass
|
|
63
|
+
else:
|
|
64
|
+
if not found_day_of_month:
|
|
65
|
+
found_day_of_month = self.visit_day_of_month(node)
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
node = CookieDate("month").parse_all(date_token)
|
|
69
|
+
except ParseError:
|
|
70
|
+
pass
|
|
71
|
+
else:
|
|
72
|
+
if not found_month:
|
|
73
|
+
found_month = self.visit_month(node)
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
node = CookieDate("year").parse_all(date_token)
|
|
78
|
+
except ParseError:
|
|
79
|
+
pass
|
|
80
|
+
else:
|
|
81
|
+
if not found_year:
|
|
82
|
+
found_year = self.visit_year(node)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if found_time is None:
|
|
86
|
+
raise ValueError("No valid time found in cookie date.")
|
|
87
|
+
if found_day_of_month is None:
|
|
88
|
+
raise ValueError("No valid day of month found in cookie date.")
|
|
89
|
+
if found_month is None:
|
|
90
|
+
raise ValueError("No valid month found in cookie date.")
|
|
91
|
+
if found_year is None:
|
|
92
|
+
raise ValueError("No valid day of month found in cookie date.")
|
|
93
|
+
|
|
94
|
+
return datetime.combine(
|
|
95
|
+
date(found_year, found_month, found_day_of_month), found_time
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def visit_date_token_list(self, node: Node) -> list[str]:
|
|
99
|
+
return list(filter(None, map(self.visit, node.children)))
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def visit_date_token(node: Node):
|
|
103
|
+
return node.value
|
|
104
|
+
|
|
105
|
+
def visit_time(self, node: Node) -> time:
|
|
106
|
+
return self.visit(node.children[0])
|
|
107
|
+
|
|
108
|
+
def visit_hms_time(self, node: Node):
|
|
109
|
+
h: int
|
|
110
|
+
m: int
|
|
111
|
+
s: int
|
|
112
|
+
# Select the time-field children by name; the ":" separators visit to
|
|
113
|
+
# None, and a zero-valued field (e.g. "00") would be lost by a
|
|
114
|
+
# truthiness filter.
|
|
115
|
+
h, m, s = (
|
|
116
|
+
self.visit(child) for child in node.children if child.name == "time-field"
|
|
117
|
+
)
|
|
118
|
+
# raises ValueError if h, m. s do not satisfy the same bounds as
|
|
119
|
+
# as specified by parsing algorithm.
|
|
120
|
+
return time(h, m, s, tzinfo=timezone.utc)
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def visit_time_field(node: Node):
|
|
124
|
+
return int(node.value)
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def visit_day_of_month(node: Node):
|
|
128
|
+
# grammar implies that int(node.value) should not throw.
|
|
129
|
+
# bounds checking as specified by parsing algorithm happens later.
|
|
130
|
+
day_of_month = int(node.value)
|
|
131
|
+
if 1 <= day_of_month <= 31:
|
|
132
|
+
return day_of_month
|
|
133
|
+
else:
|
|
134
|
+
raise ValueError("day of month is out of valid range [1, 31].")
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def visit_month(node: Node):
|
|
138
|
+
# grammar specifies case-insensitive match.
|
|
139
|
+
month_names = [
|
|
140
|
+
"jan",
|
|
141
|
+
"feb",
|
|
142
|
+
"mar",
|
|
143
|
+
"apr",
|
|
144
|
+
"may",
|
|
145
|
+
"jun",
|
|
146
|
+
"jul",
|
|
147
|
+
"aug",
|
|
148
|
+
"sep",
|
|
149
|
+
"oct",
|
|
150
|
+
"nov",
|
|
151
|
+
"dec",
|
|
152
|
+
]
|
|
153
|
+
return 1 + month_names.index(node.value.lower())
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def visit_year(node: Node):
|
|
157
|
+
value = int(node.value)
|
|
158
|
+
if 70 <= value <= 99:
|
|
159
|
+
year = 1900 + value
|
|
160
|
+
elif 0 <= value <= 69:
|
|
161
|
+
year = 2000 + value
|
|
162
|
+
else:
|
|
163
|
+
year = value
|
|
164
|
+
|
|
165
|
+
if year >= 1601:
|
|
166
|
+
return year
|
|
167
|
+
else:
|
|
168
|
+
raise ValueError("year must be at least 1601.")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_cookie_date(src: str) -> datetime:
|
|
172
|
+
"""
|
|
173
|
+
RFC 6265 specifies an algorithm to parse a cookie date. The reason is to
|
|
174
|
+
support interoperablility with servers that may not quite follow the grammar
|
|
175
|
+
as specified in RFC 6265 section 4."""
|
|
176
|
+
|
|
177
|
+
node = CookieDate("cookie-date").parse_all(src)
|
|
178
|
+
visitor = CookieDateVisitor()
|
|
179
|
+
return visitor.visit(node)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(frozen=True, kw_only=True)
|
|
183
|
+
class SetCookie(Header):
|
|
184
|
+
"""Set-Cookie header, as defined by RFC 6265.
|
|
185
|
+
|
|
186
|
+
Use :meth:`build` to construct (and validate) from pieces, or :meth:`parse` to parse a
|
|
187
|
+
raw header value with the lenient RFC 6265 section 5 algorithm.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
name: ClassVar[str] = "Set-Cookie"
|
|
191
|
+
|
|
192
|
+
cookie_name: str
|
|
193
|
+
cookie_value: str
|
|
194
|
+
domain: str | None = None
|
|
195
|
+
path: str | None = None
|
|
196
|
+
expires: datetime | None = None
|
|
197
|
+
max_age: int | None = None
|
|
198
|
+
secure: bool = False
|
|
199
|
+
http_only: bool = False
|
|
200
|
+
# None means the origin did not set a SameSite attribute; it is not serialized.
|
|
201
|
+
samesite: str | None = None
|
|
202
|
+
extension: tuple[str, ...] = ()
|
|
203
|
+
|
|
204
|
+
def __post_init__(self) -> None:
|
|
205
|
+
# Reject control characters (CR/LF/NUL, ...) in the string fields, so a direct
|
|
206
|
+
# constructor call cannot inject header content that the lenient parse() and
|
|
207
|
+
# build() paths also reject.
|
|
208
|
+
for value in (
|
|
209
|
+
self.cookie_name,
|
|
210
|
+
self.cookie_value,
|
|
211
|
+
self.domain,
|
|
212
|
+
self.path,
|
|
213
|
+
*self.extension,
|
|
214
|
+
):
|
|
215
|
+
if value is not None and _CONTROL_CHARS.intersection(value):
|
|
216
|
+
raise ValueError("Set-Cookie field contains a control character.")
|
|
217
|
+
|
|
218
|
+
@classmethod
|
|
219
|
+
def build(
|
|
220
|
+
cls,
|
|
221
|
+
cookie_name: str,
|
|
222
|
+
cookie_value: str,
|
|
223
|
+
*,
|
|
224
|
+
domain: str | None = None,
|
|
225
|
+
path: str | None = None,
|
|
226
|
+
expires: datetime | None = None,
|
|
227
|
+
max_age: int | None = None,
|
|
228
|
+
secure: bool = False,
|
|
229
|
+
http_only: bool = False,
|
|
230
|
+
samesite: Literal["Strict", "Lax", "None"] | None = None,
|
|
231
|
+
extension: list[str] | None = None,
|
|
232
|
+
) -> Self:
|
|
233
|
+
"""Build a validated Set-Cookie from its pieces."""
|
|
234
|
+
# Runtime guards for callers that ignore the type annotations.
|
|
235
|
+
if not isinstance(cookie_name, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
236
|
+
raise TypeError("cookie_name must be str.")
|
|
237
|
+
if not isinstance(cookie_value, str): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
238
|
+
raise TypeError("cookie_value must be str.")
|
|
239
|
+
try:
|
|
240
|
+
cookie_name = rfc6265.Rule("cookie-name").parse_all(cookie_name).value
|
|
241
|
+
cookie_value = rfc6265.Rule("cookie-value").parse_all(cookie_value).value
|
|
242
|
+
except ParseError as exc:
|
|
243
|
+
raise ValueError("Invalid argument value.") from exc
|
|
244
|
+
if domain is not None:
|
|
245
|
+
try:
|
|
246
|
+
rfc6265.Rule("domain-value").parse_all(domain)
|
|
247
|
+
except ParseError as exc:
|
|
248
|
+
raise ValueError("Invalid domain value.") from exc
|
|
249
|
+
if path is not None:
|
|
250
|
+
try:
|
|
251
|
+
rfc6265.Rule("path-value").parse_all(path)
|
|
252
|
+
except ParseError as exc:
|
|
253
|
+
raise ValueError("Invalid path value.") from exc
|
|
254
|
+
if expires is not None and expires.year < 1601:
|
|
255
|
+
raise ValueError("Expires year must be at least 1601.")
|
|
256
|
+
# the RFC 6265 grammar requires max-age to be positive; this is almost universally
|
|
257
|
+
# ignored, so we go with the crowd and clamp negatives to 0.
|
|
258
|
+
normalized_max_age = 0 if max_age and max_age < 0 else max_age
|
|
259
|
+
try:
|
|
260
|
+
ext = (
|
|
261
|
+
tuple(
|
|
262
|
+
rfc6265.Rule("extension-av").parse_all(x).value for x in extension
|
|
263
|
+
)
|
|
264
|
+
if extension
|
|
265
|
+
else ()
|
|
266
|
+
)
|
|
267
|
+
except ParseError as exc:
|
|
268
|
+
raise ValueError("Invalid extension value.") from exc
|
|
269
|
+
return cls(
|
|
270
|
+
cookie_name=cookie_name,
|
|
271
|
+
cookie_value=cookie_value,
|
|
272
|
+
domain=domain,
|
|
273
|
+
path=path,
|
|
274
|
+
expires=expires,
|
|
275
|
+
max_age=normalized_max_age,
|
|
276
|
+
secure=secure,
|
|
277
|
+
http_only=http_only,
|
|
278
|
+
samesite=samesite,
|
|
279
|
+
extension=ext,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
@classmethod
|
|
283
|
+
def parse(cls, value: str) -> Self:
|
|
284
|
+
"""Parse a Set-Cookie value using the lenient RFC 6265 section 5 algorithm, which
|
|
285
|
+
is more forgiving than the section 4 grammar for interoperability."""
|
|
286
|
+
cls._check_length(value)
|
|
287
|
+
attrs = cls._parse_value(value)
|
|
288
|
+
attrs["extension"] = tuple(attrs["extension"])
|
|
289
|
+
return cls(**attrs)
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def value(self) -> str:
|
|
293
|
+
header_value = [f"{self.cookie_name}={self.cookie_value}"]
|
|
294
|
+
if self.domain is not None:
|
|
295
|
+
header_value.append(f"Domain={self.domain}")
|
|
296
|
+
if self.path is not None:
|
|
297
|
+
header_value.append(f"Path={self.path}")
|
|
298
|
+
if self.expires is not None:
|
|
299
|
+
header_value.append(f"Expires={imf_fixdate(self.expires)}")
|
|
300
|
+
if self.max_age is not None:
|
|
301
|
+
header_value.append(f"Max-Age={self.max_age}")
|
|
302
|
+
if self.samesite is not None:
|
|
303
|
+
header_value.append(f"SameSite={self.samesite}")
|
|
304
|
+
if self.secure:
|
|
305
|
+
header_value.append("Secure")
|
|
306
|
+
if self.http_only:
|
|
307
|
+
header_value.append("HttpOnly")
|
|
308
|
+
if self.extension:
|
|
309
|
+
header_value.append("; ".join(self.extension))
|
|
310
|
+
|
|
311
|
+
return "; ".join(header_value)
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def default_path(request_path: str):
|
|
315
|
+
if request_path:
|
|
316
|
+
if request_path[0] == "/":
|
|
317
|
+
cmps = request_path.split("/")[1:-1]
|
|
318
|
+
return "/" + "/".join(x for x in cmps)
|
|
319
|
+
else:
|
|
320
|
+
return "/"
|
|
321
|
+
else:
|
|
322
|
+
return "/"
|
|
323
|
+
|
|
324
|
+
def expiry_time(self, *, now: datetime | None = None):
|
|
325
|
+
"""expiry time is the datetime at which the cookie expires. It is computed
|
|
326
|
+
as specified in RFC 6265."""
|
|
327
|
+
if self.max_age is not None:
|
|
328
|
+
if self.max_age > 0:
|
|
329
|
+
if now is None:
|
|
330
|
+
now = datetime.now(timezone.utc)
|
|
331
|
+
return now + timedelta(seconds=self.max_age)
|
|
332
|
+
else:
|
|
333
|
+
return datetime.fromtimestamp(0, timezone.utc)
|
|
334
|
+
elif self.expires is not None:
|
|
335
|
+
return self.expires
|
|
336
|
+
else:
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def _parse_value(src: str, *, log: Logger = _LOGGER) -> dict[str, Any]:
|
|
341
|
+
cookie_attrs: dict[str, Any] = {}
|
|
342
|
+
|
|
343
|
+
for idx, x in enumerate(src):
|
|
344
|
+
if x in _CONTROL_CHARS:
|
|
345
|
+
raise ValueError(f"Illegal character in value at offset {idx}")
|
|
346
|
+
|
|
347
|
+
name_value_pair, attribute_src = (
|
|
348
|
+
_ if len(_ := src.split(";", 1)) == 2 else (_[0], "")
|
|
349
|
+
)
|
|
350
|
+
name_value_split = name_value_pair.split("=", 1)
|
|
351
|
+
if len(name_value_split) != 2:
|
|
352
|
+
# RFC 6265 section 5.2 step 2: a name-value pair lacking "=" causes
|
|
353
|
+
# the entire set-cookie-string to be ignored.
|
|
354
|
+
raise ValueError("Set-Cookie name-value pair lacks '='.")
|
|
355
|
+
cookie_name, cookie_value = (x.strip(" \t") for x in name_value_split)
|
|
356
|
+
if not cookie_name:
|
|
357
|
+
# RFC 6265 section 5.2 step 5: an empty cookie-name is ignored.
|
|
358
|
+
raise ValueError("Set-Cookie name is empty.")
|
|
359
|
+
# if len(cookie_name) + len(cookie_value) > 4096: this test is contained in RFC 6265bis, a new draft.
|
|
360
|
+
# raise ValueError("Cookie name-value pair exceeds 4096 characters.")
|
|
361
|
+
|
|
362
|
+
cookie_attrs["cookie_name"] = cookie_name
|
|
363
|
+
cookie_attrs["cookie_value"] = cookie_value
|
|
364
|
+
cookie_attrs["extension"] = cast(list[str], [])
|
|
365
|
+
|
|
366
|
+
for attr in [x for x in attribute_src.split(";") if x]:
|
|
367
|
+
attr_name, attr_value = (
|
|
368
|
+
_
|
|
369
|
+
if len(_ := [x.strip(" \t") for x in attr.split("=", 1)]) == 2
|
|
370
|
+
else (_[0], "")
|
|
371
|
+
)
|
|
372
|
+
# if len(attr_value) > 1024: this test is contained in RFC 6265bis, a new draft.
|
|
373
|
+
# ignore. log?
|
|
374
|
+
# continue
|
|
375
|
+
matchname = attr_name.lower()
|
|
376
|
+
if matchname == "expires":
|
|
377
|
+
try:
|
|
378
|
+
cookie_attrs["expires"] = parse_cookie_date(attr_value)
|
|
379
|
+
except ValueError:
|
|
380
|
+
log.info(f"Invalid Expires value {attr_value}.")
|
|
381
|
+
elif matchname == "max-age":
|
|
382
|
+
try:
|
|
383
|
+
cookie_attrs["max_age"] = int(attr_value)
|
|
384
|
+
except ValueError:
|
|
385
|
+
# ignore.
|
|
386
|
+
log.info(f"Invalid Max-Age value {attr_value}.")
|
|
387
|
+
elif matchname == "domain":
|
|
388
|
+
# An empty Domain attribute-value carries no cookie-domain; ignore it.
|
|
389
|
+
if attr_value:
|
|
390
|
+
cookie_attrs["domain"] = (
|
|
391
|
+
attr_value[1:] if attr_value.startswith(".") else attr_value
|
|
392
|
+
).lower()
|
|
393
|
+
elif matchname == "path":
|
|
394
|
+
# when cookie path is '' or not an absolute path, the default path should be used for comparison. But computation
|
|
395
|
+
# of the default path depends on the request path. We don't want to entangle parsing with the request,
|
|
396
|
+
# so we leave it to the user to call default_path as needed.
|
|
397
|
+
cookie_attrs["path"] = (
|
|
398
|
+
attr_value if attr_value.startswith("/") else None
|
|
399
|
+
)
|
|
400
|
+
elif matchname == "secure":
|
|
401
|
+
cookie_attrs["secure"] = True
|
|
402
|
+
elif matchname == "httponly":
|
|
403
|
+
cookie_attrs["http_only"] = True
|
|
404
|
+
elif matchname == "samesite":
|
|
405
|
+
# Store the canonical spelling; an unrecognized value is treated
|
|
406
|
+
# as absent (None) so it is never serialized as "SameSite=Default".
|
|
407
|
+
cookie_attrs["samesite"] = {
|
|
408
|
+
"strict": "Strict",
|
|
409
|
+
"lax": "Lax",
|
|
410
|
+
"none": "None",
|
|
411
|
+
}.get(attr_value.lower())
|
|
412
|
+
else:
|
|
413
|
+
# extension-av
|
|
414
|
+
# RFC 6256 Section 5 explicitly ignores extension-av in its loose parsing algorithm.
|
|
415
|
+
# In the Set-Cookie grammar, attributes are delimited by ";" SP. This algorithm
|
|
416
|
+
# splits attributes on ";", then strips SP, TAB from attribute name, value. But "SP" is a valid
|
|
417
|
+
# attribute-av character, so we only strip the leading space, if present.
|
|
418
|
+
cookie_attrs["extension"].append(attr[1:] if attr[0] == " " else attr)
|
|
419
|
+
return cookie_attrs
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Strict-Transport-Security header class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
from abnf import Rule
|
|
7
|
+
from abnf.grammars import rfc6797
|
|
8
|
+
from typing_extensions import Self
|
|
9
|
+
|
|
10
|
+
from http_fields.header import Header
|
|
11
|
+
from http_fields.parsedobjs import NonNegativeInt
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _unquote(value: str) -> str:
|
|
15
|
+
"""Strip a surrounding quoted-string (RFC 6797 directive-value), unescaping
|
|
16
|
+
quoted-pairs; a bare token is returned unchanged."""
|
|
17
|
+
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
|
18
|
+
inner = value[1:-1]
|
|
19
|
+
out: list[str] = []
|
|
20
|
+
i = 0
|
|
21
|
+
while i < len(inner):
|
|
22
|
+
if inner[i] == "\\" and i + 1 < len(inner):
|
|
23
|
+
out.append(inner[i + 1])
|
|
24
|
+
i += 2
|
|
25
|
+
else:
|
|
26
|
+
out.append(inner[i])
|
|
27
|
+
i += 1
|
|
28
|
+
return "".join(out)
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class StrictTransportSecurity(Header):
|
|
34
|
+
"""Strict-Transport-Security (HSTS) header, as defined by RFC 6797."""
|
|
35
|
+
|
|
36
|
+
name: ClassVar[str] = "Strict-Transport-Security"
|
|
37
|
+
rule: ClassVar[Rule] = rfc6797.Rule("Strict-Transport-Security")
|
|
38
|
+
|
|
39
|
+
max_age: NonNegativeInt
|
|
40
|
+
include_subdomains: bool = False
|
|
41
|
+
preload: bool = False
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, max_age: int, include_subdomains: bool = False, preload: bool = False
|
|
45
|
+
) -> None:
|
|
46
|
+
object.__setattr__(self, "max_age", NonNegativeInt(max_age))
|
|
47
|
+
object.__setattr__(self, "include_subdomains", include_subdomains)
|
|
48
|
+
object.__setattr__(self, "preload", preload)
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def parse(cls, value: str) -> Self:
|
|
52
|
+
# the grammar rule matches the whole header line, so prepend the field name.
|
|
53
|
+
node = cls._prefixed_node(value)
|
|
54
|
+
max_age = 0
|
|
55
|
+
max_age_seen = False
|
|
56
|
+
include_subdomains = False
|
|
57
|
+
preload = False
|
|
58
|
+
for directive in (c for c in node.children if c.name == "directive"):
|
|
59
|
+
dname = next(
|
|
60
|
+
c.value for c in directive.children if c.name == "directive-name"
|
|
61
|
+
).lower()
|
|
62
|
+
if dname == "max-age":
|
|
63
|
+
dvalue = _unquote(
|
|
64
|
+
next(
|
|
65
|
+
(
|
|
66
|
+
c.value
|
|
67
|
+
for c in directive.children
|
|
68
|
+
if c.name == "directive-value"
|
|
69
|
+
),
|
|
70
|
+
"",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
# RFC 6797 section 6.1: directive-value = token / quoted-string.
|
|
74
|
+
# A max-age directive with no value is malformed; leave max_age_seen
|
|
75
|
+
# False so the "requires a max-age directive" error below fires.
|
|
76
|
+
if dvalue:
|
|
77
|
+
max_age = int(dvalue)
|
|
78
|
+
max_age_seen = True
|
|
79
|
+
elif dname == "includesubdomains":
|
|
80
|
+
include_subdomains = True
|
|
81
|
+
elif dname == "preload":
|
|
82
|
+
preload = True
|
|
83
|
+
if not max_age_seen:
|
|
84
|
+
# RFC 6797 section 6.1.1: an STS header field without max-age is
|
|
85
|
+
# ignored; surface that as an error rather than a spurious max-age=0.
|
|
86
|
+
raise ValueError("Strict-Transport-Security requires a max-age directive.")
|
|
87
|
+
return cls(max_age, include_subdomains, preload)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def value(self) -> str:
|
|
91
|
+
parts = [f"max-age={self.max_age}"]
|
|
92
|
+
if self.include_subdomains:
|
|
93
|
+
parts.append("includeSubDomains")
|
|
94
|
+
if self.preload:
|
|
95
|
+
parts.append("preload")
|
|
96
|
+
return "; ".join(parts)
|