|
1 | 1 | import copy |
2 | 2 | from io import BytesIO |
3 | 3 | from itertools import chain |
| 4 | +from unittest import mock |
4 | 5 | from urllib.parse import urlencode |
5 | 6 |
|
6 | 7 | from django.core.exceptions import DisallowedHost |
|
11 | 12 | RawPostDataException, |
12 | 13 | UnreadablePostError, |
13 | 14 | ) |
14 | | -from django.http.multipartparser import MultiPartParserError |
| 15 | +from django.http.multipartparser import LazyStream, MultiPartParserError |
15 | 16 | from django.http.request import split_domain_port |
16 | 17 | from django.test import RequestFactory, SimpleTestCase, override_settings |
17 | | -from django.test.client import FakePayload |
| 18 | +from django.test.client import BOUNDARY, MULTIPART_CONTENT, FakePayload |
18 | 19 |
|
19 | 20 |
|
20 | 21 | class RequestsTests(SimpleTestCase): |
@@ -537,6 +538,115 @@ def test_POST_after_body_read_and_stream_read(self): |
537 | 538 | self.assertEqual(request.read(1), b"n") |
538 | 539 | self.assertEqual(request.POST, {"name": ["value"]}) |
539 | 540 |
|
| 541 | + def test_multipart_post_field_with_base64(self): |
| 542 | + payload = FakePayload( |
| 543 | + "\r\n".join( |
| 544 | + [ |
| 545 | + f"--{BOUNDARY}", |
| 546 | + 'Content-Disposition: form-data; name="name"', |
| 547 | + "Content-Transfer-Encoding: base64", |
| 548 | + "", |
| 549 | + "dmFsdWU=", |
| 550 | + f"--{BOUNDARY}--", |
| 551 | + "", |
| 552 | + ] |
| 553 | + ) |
| 554 | + ) |
| 555 | + request = WSGIRequest( |
| 556 | + { |
| 557 | + "REQUEST_METHOD": "POST", |
| 558 | + "CONTENT_TYPE": MULTIPART_CONTENT, |
| 559 | + "CONTENT_LENGTH": len(payload), |
| 560 | + "wsgi.input": payload, |
| 561 | + } |
| 562 | + ) |
| 563 | + request.body # evaluate |
| 564 | + self.assertEqual(request.POST, {"name": ["value"]}) |
| 565 | + |
| 566 | + def test_multipart_post_field_with_invalid_base64(self): |
| 567 | + payload = FakePayload( |
| 568 | + "\r\n".join( |
| 569 | + [ |
| 570 | + f"--{BOUNDARY}", |
| 571 | + 'Content-Disposition: form-data; name="name"', |
| 572 | + "Content-Transfer-Encoding: base64", |
| 573 | + "", |
| 574 | + "123", |
| 575 | + f"--{BOUNDARY}--", |
| 576 | + "", |
| 577 | + ] |
| 578 | + ) |
| 579 | + ) |
| 580 | + request = WSGIRequest( |
| 581 | + { |
| 582 | + "REQUEST_METHOD": "POST", |
| 583 | + "CONTENT_TYPE": MULTIPART_CONTENT, |
| 584 | + "CONTENT_LENGTH": len(payload), |
| 585 | + "wsgi.input": payload, |
| 586 | + } |
| 587 | + ) |
| 588 | + request.body # evaluate |
| 589 | + self.assertEqual(request.POST, {"name": ["123"]}) |
| 590 | + |
| 591 | + def test_multipart_file_upload_base64_whitespace_heavy(self): |
| 592 | + # Fake a file upload with base64-encoded content including mostly |
| 593 | + # whitespaces across chunk boundaries. |
| 594 | + payload = FakePayload( |
| 595 | + "\r\n".join( |
| 596 | + [ |
| 597 | + f"--{BOUNDARY}", |
| 598 | + 'Content-Disposition: form-data; name="file"; filename="test.txt"', |
| 599 | + "Content-Type: application/octet-stream", |
| 600 | + "Content-Transfer-Encoding: base64", |
| 601 | + "", |
| 602 | + ] |
| 603 | + ) |
| 604 | + ) |
| 605 | + # "AAAA" decodes to b"\x00\x00\x00". Whitespace (70000 bytes) spans the |
| 606 | + # default 64KB chunk boundary, hence the alignment loop is exercised. |
| 607 | + payload.write(b"\r\n" + b"AAA" + b" " * 70000 + b"A" + b"\r\n") |
| 608 | + payload.write("--" + BOUNDARY + "--\r\n") |
| 609 | + request = WSGIRequest( |
| 610 | + { |
| 611 | + "REQUEST_METHOD": "POST", |
| 612 | + "CONTENT_TYPE": MULTIPART_CONTENT, |
| 613 | + "CONTENT_LENGTH": len(payload), |
| 614 | + "wsgi.input": payload, |
| 615 | + } |
| 616 | + ) |
| 617 | + reads = [] |
| 618 | + original_read = LazyStream.read |
| 619 | + |
| 620 | + def counting_read(self_stream, size=None): |
| 621 | + reads.append(size) |
| 622 | + return original_read(self_stream, size) |
| 623 | + |
| 624 | + with mock.patch.object(LazyStream, "read", counting_read): |
| 625 | + files = request.FILES |
| 626 | + |
| 627 | + self.assertEqual(len(files), 1) |
| 628 | + self.assertEqual(files["file"].read(), b"\x00\x00\x00") |
| 629 | + |
| 630 | + # The alignment loop must read in `chunk-sized` units rather than one |
| 631 | + # byte at a time, otherwise each whitespace byte triggers a separate |
| 632 | + # read() call with a costly internal unget() cycle. |
| 633 | + # Parsing this payload should issue exactly 8 LazyStream.read() calls: |
| 634 | + # 1. main_stream.read(1) -- BoundaryIter.__init__ probe, preamble |
| 635 | + # 2. sub_stream.read(1024) -- parse_boundary_stream, preamble headers |
| 636 | + # 3. main_stream.read(1) -- BoundaryIter.__init__ probe, file field |
| 637 | + # 4. field_stream.read(1024) -- parse_boundary_stream, file headers |
| 638 | + # 5. field_stream.read(65536)-- base64 alignment loop: one chunk-sized |
| 639 | + # read to find the non-whitespace bytes |
| 640 | + # needed to complete the 4-byte base64 |
| 641 | + # group that spans the chunk boundary |
| 642 | + # 6. main_stream.read(1) -- BoundaryIter.__init__ probe, epilogue |
| 643 | + # 7. sub_stream.read(1024) -- parse_boundary_stream, epilogue headers |
| 644 | + # 8. main_stream.read(1) -- BoundaryIter.__init__ probe, exhausted |
| 645 | + # stream; returns b"" and stops iteration |
| 646 | + # A byte-at-a-time implementation of read() in step 5 would do instead |
| 647 | + # one read(1) per whitespace byte past the chunk boundary (4488 calls). |
| 648 | + self.assertEqual(reads, [1, 1024, 1, 1024, 65536, 1, 1024, 1]) |
| 649 | + |
540 | 650 | def test_POST_after_body_read_and_stream_read_multipart(self): |
541 | 651 | """ |
542 | 652 | POST should be populated even if body is read first, and then |
|
0 commit comments