leblob 0.4.0.dev1__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.
leblob/s3/post_form.py ADDED
@@ -0,0 +1,344 @@
1
+ """Browser form uploads: reading `multipart/form-data`, and judging its policy.
2
+
3
+ The form can hold gigabytes, so `FormParser` takes it a chunk at a time, and is pure.
4
+ A body that ends before its closing delimiter never reports the end of its file, which
5
+ is what stops a cut-off upload from becoming an object.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+ from datetime import datetime
16
+ from enum import Enum, auto
17
+ from typing import TYPE_CHECKING
18
+
19
+ from leblob.s3 import errors
20
+ from leblob.s3.errors import S3Error
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterator, Mapping
24
+
25
+ MAX_FIELDS_BYTES = 20 * 1024 # As AWS limits what comes before the file.
26
+ _MAX_BOUNDARY_BYTES = 200
27
+ _HEADERS_END = b"\r\n\r\n"
28
+ _CLOSING = b"--"
29
+ _LINE_END = b"\r\n"
30
+ _BOUNDARY = re.compile(r'boundary=(?:"([^"]+)"|([^\s;]+))', re.IGNORECASE)
31
+ _DISPOSITION_NAME = re.compile(r'[;\s]name="((?:[^"\\]|\\.)*)"', re.IGNORECASE)
32
+ _DISPOSITION_FILENAME = re.compile(r'[;\s]filename="((?:[^"\\]|\\.)*)"', re.IGNORECASE)
33
+ _ESCAPED = re.compile(r"\\(.)")
34
+ _EXPIRATION = re.compile(
35
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z"
36
+ )
37
+ FILE_FIELD = "file"
38
+ _NOT_FOR_THE_POLICY = frozenset({"file", "policy", "x-amz-signature"})
39
+ # A checksum can only make a form stricter, so it needs no one's leave.
40
+ _EXEMPT_PREFIXES = ("x-ignore-", "x-amz-checksum-")
41
+ _LENGTH_CONDITION = "content-length-range"
42
+ _EQUALS_ARGUMENTS = 3
43
+ _REFUSAL = "Invalid according to Policy: "
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Field:
48
+ name: str
49
+ value: str
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class FileStart:
54
+ filename: str
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class FileData:
59
+ data: bytes
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class FileEnd:
64
+ pass
65
+
66
+
67
+ type Event = Field | FileStart | FileData | FileEnd
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class SizeLimits:
72
+ smallest: int
73
+ largest: int | None
74
+
75
+
76
+ class _State(Enum):
77
+ BEFORE_FIRST = auto()
78
+ AFTER_DELIMITER = auto()
79
+ HEADERS = auto()
80
+ VALUE = auto()
81
+ FILE = auto()
82
+ SKIPPING = auto()
83
+ DONE = auto()
84
+
85
+
86
+ def parse_boundary(content_type: str) -> bytes:
87
+ found = _BOUNDARY.search(content_type)
88
+ boundary = (found[1] or found[2]) if found else ""
89
+ if not boundary or len(boundary) > _MAX_BOUNDARY_BYTES:
90
+ raise S3Error(errors.MALFORMED_POST, "The form has no usable boundary.")
91
+ return boundary.encode("latin-1")
92
+
93
+
94
+ class FormParser:
95
+ """Turn the chunks of a form into events. Only the first file is reported.
96
+
97
+ S3 ignores anything after the file, so such parts are skipped without keeping them.
98
+ """
99
+
100
+ def __init__(self, boundary: bytes) -> None:
101
+ self._delimiter = b"\r\n--" + boundary
102
+ # So that the first delimiter, which has no line end of its own, looks the same.
103
+ self._buffer = bytearray(_LINE_END)
104
+ self._state = _State.BEFORE_FIRST
105
+ self._seen_file = False
106
+ self._field_name = ""
107
+ self._field_bytes = 0
108
+
109
+ def feed(self, chunk: bytes) -> Iterator[Event]:
110
+ self._buffer += chunk
111
+ progress = True
112
+ while progress:
113
+ progress = False
114
+ for event in self._step():
115
+ progress = True
116
+ if event is not None:
117
+ yield event
118
+ self._check_size()
119
+
120
+ def finish(self) -> None:
121
+ if self._state is not _State.DONE:
122
+ raise S3Error(errors.MALFORMED_POST, "The form ends before it should.")
123
+
124
+ def _check_size(self) -> None:
125
+ if not self._seen_file and self._field_bytes + len(self._buffer) > (
126
+ MAX_FIELDS_BYTES
127
+ ):
128
+ raise S3Error(errors.MAX_POST_PRE_DATA)
129
+
130
+ def _step(self) -> Iterator[Event | None]:
131
+ """Do what the buffer allows of one state. Yields once for each advance."""
132
+ if self._state is _State.BEFORE_FIRST:
133
+ yield from self._skip_to_delimiter()
134
+ elif self._state is _State.AFTER_DELIMITER:
135
+ yield from self._read_after_delimiter()
136
+ elif self._state is _State.HEADERS:
137
+ yield from self._read_headers()
138
+ elif self._state is _State.VALUE:
139
+ yield from self._read_value()
140
+ elif self._state is _State.FILE:
141
+ yield from self._read_file()
142
+ elif self._state is _State.SKIPPING:
143
+ yield from self._skip_to_delimiter()
144
+ else:
145
+ self._buffer.clear()
146
+
147
+ def _take(self, length: int, then_skip: int = 0) -> bytes:
148
+ taken = bytes(self._buffer[:length])
149
+ del self._buffer[: length + then_skip]
150
+ return taken
151
+
152
+ def _skip_to_delimiter(self) -> Iterator[None]:
153
+ at = self._buffer.find(self._delimiter)
154
+ if at >= 0:
155
+ self._take(at, len(self._delimiter))
156
+ self._state = _State.AFTER_DELIMITER
157
+ yield None
158
+ elif self._seen_file:
159
+ # Keep only what could be the start of a delimiter.
160
+ keep = len(self._delimiter) - 1
161
+ if len(self._buffer) > keep:
162
+ self._take(len(self._buffer) - keep)
163
+
164
+ def _read_after_delimiter(self) -> Iterator[None]:
165
+ if len(self._buffer) < len(_CLOSING):
166
+ return
167
+ ending = self._take(len(_CLOSING))
168
+ if ending == _CLOSING:
169
+ self._state = _State.DONE
170
+ elif ending == _LINE_END:
171
+ self._state = _State.HEADERS
172
+ else:
173
+ raise S3Error(errors.MALFORMED_POST, "Bad text after a form boundary.")
174
+ yield None
175
+
176
+ def _read_headers(self) -> Iterator[Event | None]:
177
+ at = self._buffer.find(_HEADERS_END)
178
+ if at < 0:
179
+ if self._seen_file and len(self._buffer) > MAX_FIELDS_BYTES:
180
+ raise S3Error(
181
+ errors.MALFORMED_POST, "A form part's headers are too long."
182
+ )
183
+ return
184
+ headers = self._decode(self._take(at, len(_HEADERS_END)))
185
+ self._field_bytes += at + len(_HEADERS_END)
186
+ disposition = next(
187
+ (
188
+ line
189
+ for line in headers.split("\r\n")
190
+ if line.lower().startswith("content-disposition:")
191
+ ),
192
+ "",
193
+ )
194
+ name = _DISPOSITION_NAME.search(disposition)
195
+ if name is None:
196
+ raise S3Error(errors.MALFORMED_POST, "A form part has no name.")
197
+ self._field_name = _unescape(name[1]).lower()
198
+ if self._seen_file:
199
+ self._state = _State.SKIPPING
200
+ yield None
201
+ elif self._field_name == FILE_FIELD:
202
+ filename = _DISPOSITION_FILENAME.search(disposition)
203
+ self._seen_file = True
204
+ self._state = _State.FILE
205
+ yield FileStart(_unescape(filename[1]) if filename else "")
206
+ else:
207
+ self._state = _State.VALUE
208
+ yield None
209
+
210
+ def _read_value(self) -> Iterator[Event]:
211
+ at = self._buffer.find(self._delimiter)
212
+ if at < 0:
213
+ return
214
+ self._field_bytes += at + len(self._delimiter)
215
+ value = self._decode(self._take(at, len(self._delimiter)))
216
+ self._state = _State.AFTER_DELIMITER
217
+ yield Field(self._field_name, value)
218
+
219
+ def _read_file(self) -> Iterator[Event]:
220
+ at = self._buffer.find(self._delimiter)
221
+ if at >= 0:
222
+ data = self._take(at, len(self._delimiter))
223
+ if data:
224
+ yield FileData(data)
225
+ self._state = _State.AFTER_DELIMITER
226
+ yield FileEnd()
227
+ return
228
+ safe = len(self._buffer) - (len(self._delimiter) - 1)
229
+ if safe > 0:
230
+ yield FileData(self._take(safe))
231
+
232
+ @staticmethod
233
+ def _decode(data: bytes) -> str:
234
+ try:
235
+ return data.decode()
236
+ except UnicodeDecodeError:
237
+ raise S3Error(errors.MALFORMED_POST, "A form field isn't UTF-8.") from None
238
+
239
+
240
+ def _unescape(text: str) -> str:
241
+ return _ESCAPED.sub(r"\1", text)
242
+
243
+
244
+ def check_policy(fields: Mapping[str, str], now: float) -> SizeLimits:
245
+ """Hold a form to its policy: nothing it doesn't allow, nor less than it asks."""
246
+ document = _load_policy(fields["policy"])
247
+ conditions = document.get("conditions")
248
+ if not isinstance(conditions, list):
249
+ raise S3Error(errors.INVALID_POLICY, "The policy has no conditions.")
250
+ _check_expiration(document.get("expiration"), now)
251
+
252
+ covered: set[str] = set()
253
+ limits = SizeLimits(0, None)
254
+ for condition in conditions:
255
+ if _is_length_condition(condition):
256
+ limits = _narrowed(limits, condition)
257
+ else:
258
+ covered.add(_check_condition(condition, fields))
259
+ for name in fields:
260
+ exempt = name in _NOT_FOR_THE_POLICY or name.startswith(_EXEMPT_PREFIXES)
261
+ if not exempt and name not in covered:
262
+ raise S3Error(errors.ACCESS_DENIED, f"{_REFUSAL}Extra field: {name}.")
263
+ return limits
264
+
265
+
266
+ def _load_policy(encoded: str) -> dict[str, object]:
267
+ try:
268
+ document = json.loads(base64.b64decode(encoded, validate=True))
269
+ except (binascii.Error, ValueError, RecursionError):
270
+ raise S3Error(errors.INVALID_POLICY, "The policy can't be read.") from None
271
+ if not isinstance(document, dict):
272
+ raise S3Error(errors.INVALID_POLICY, "The policy can't be read.")
273
+ return document
274
+
275
+
276
+ def _check_expiration(expiration: object, now: float) -> None:
277
+ if not isinstance(expiration, str):
278
+ raise S3Error(errors.INVALID_POLICY, "The policy has no expiration.")
279
+ if _EXPIRATION.fullmatch(expiration) is None:
280
+ raise S3Error(errors.INVALID_POLICY, "Bad expiration in the policy.")
281
+ try:
282
+ moment = datetime.fromisoformat(expiration)
283
+ except ValueError:
284
+ raise S3Error(errors.INVALID_POLICY, "Bad expiration in the policy.") from None
285
+ if moment.timestamp() < now:
286
+ raise S3Error(errors.ACCESS_DENIED, f"{_REFUSAL}Policy expired.")
287
+
288
+
289
+ def _is_length_condition(condition: object) -> bool:
290
+ return (
291
+ isinstance(condition, list)
292
+ and bool(condition)
293
+ and isinstance(condition[0], str)
294
+ and condition[0].lower() == _LENGTH_CONDITION
295
+ )
296
+
297
+
298
+ def _narrowed(limits: SizeLimits, condition: list[object]) -> SizeLimits:
299
+ try:
300
+ _, smallest, largest = condition
301
+ low, high = int(str(smallest)), int(str(largest))
302
+ except ValueError:
303
+ raise S3Error(errors.INVALID_POLICY, "Bad content-length-range.") from None
304
+ if low < 0 or high < low:
305
+ raise S3Error(errors.INVALID_POLICY, "Bad content-length-range.")
306
+ return SizeLimits(
307
+ max(limits.smallest, low),
308
+ high if limits.largest is None else min(limits.largest, high),
309
+ )
310
+
311
+
312
+ def _check_condition(condition: object, fields: Mapping[str, str]) -> str:
313
+ """Check a condition holds, and return the field it covers."""
314
+ if isinstance(condition, dict) and len(condition) == 1:
315
+ [(name, wanted)] = condition.items()
316
+ return _check_field("eq", f"${name}", wanted, fields)
317
+ if isinstance(condition, list) and len(condition) == _EQUALS_ARGUMENTS:
318
+ operator, name, wanted = condition
319
+ return _check_field(operator, name, wanted, fields)
320
+ raise S3Error(errors.INVALID_POLICY, "A policy condition can't be read.")
321
+
322
+
323
+ def _check_field(
324
+ operator: object, name: object, wanted: object, fields: Mapping[str, str]
325
+ ) -> str:
326
+ if not (
327
+ isinstance(operator, str)
328
+ and isinstance(name, str)
329
+ and name.startswith("$")
330
+ and isinstance(wanted, str)
331
+ ):
332
+ raise S3Error(errors.INVALID_POLICY, "A policy condition can't be read.")
333
+ field = name[1:].lower()
334
+ value = fields.get(field)
335
+ verb = operator.lower()
336
+ if verb == "eq":
337
+ holds = value == wanted
338
+ elif verb == "starts-with":
339
+ holds = value is not None and value.startswith(wanted)
340
+ else:
341
+ raise S3Error(errors.INVALID_POLICY, f"Unknown condition: {operator!r}.")
342
+ if not holds:
343
+ raise S3Error(errors.ACCESS_DENIED, f"{_REFUSAL}Condition failed: {field}.")
344
+ return field