|
31 | 31 | _KINDS = frozenset({"rss", "atom", "unknown"}) |
32 | 32 | _SAFE_ERROR = re.compile(r"^[a-z][a-z0-9_]*$") |
33 | 33 | _ATOM = "http://www.w3.org/2005/Atom" |
| 34 | +MAX_XML_BYTES = 1024 * 1024 |
| 35 | +MAX_XML_DEPTH = 32 |
| 36 | +MAX_XML_NODES = 10000 |
| 37 | +MAX_XML_TEXT_BYTES = 256 * 1024 |
| 38 | +MAX_XML_ATTRIBUTES = 128 |
| 39 | +_FORBIDDEN_DECLARATION = re.compile(rb"<!\s*(?:doctype|entity|element|attlist|notation)\b|\b(?:system|public)\b", re.IGNORECASE) |
34 | 40 |
|
35 | 41 |
|
36 | 42 | class FetchAcceptanceError(ValueError): |
@@ -80,13 +86,41 @@ def _feed_result(feed_id: str, feed_url: str, kind: str, accepted: int, rejected |
80 | 86 | return {"feed_id": feed_id, "feed_url": feed_url, "kind": kind, "accepted_row_count": accepted, "rejected_row_count": rejected, "state": state, "error_code": error_code} |
81 | 87 |
|
82 | 88 |
|
83 | | -def classify_feed_payload(feed_id: str, feed_url: str, payload: bytes) -> dict[str, object]: |
| 89 | +def _parse_bounded_xml(payload: bytes) -> ET.Element: |
84 | 90 | if type(payload) is not bytes: |
85 | 91 | raise _fail("feed_payload_invalid") |
| 92 | + if len(payload) > MAX_XML_BYTES: |
| 93 | + raise _fail("xml_oversize") |
| 94 | + if _FORBIDDEN_DECLARATION.search(payload): |
| 95 | + raise _fail("xml_forbidden_declaration") |
86 | 96 | try: |
87 | 97 | root = ET.fromstring(payload) |
88 | | - except (ET.ParseError, UnicodeError, ValueError): |
89 | | - return _feed_result(feed_id, feed_url, "unknown", 0, 0, "failed", "xml_invalid") |
| 98 | + except (ET.ParseError, UnicodeError, ValueError, RecursionError): |
| 99 | + raise _fail("xml_invalid") from None |
| 100 | + nodes = 0 |
| 101 | + text_bytes = 0 |
| 102 | + stack: list[tuple[ET.Element, int]] = [(root, 1)] |
| 103 | + while stack: |
| 104 | + element, depth = stack.pop() |
| 105 | + nodes += 1 |
| 106 | + if nodes > MAX_XML_NODES or depth > MAX_XML_DEPTH or len(element.attrib) > MAX_XML_ATTRIBUTES: |
| 107 | + raise _fail("xml_structure_over_limit") |
| 108 | + for text in (element.text, element.tail): |
| 109 | + if text is not None: |
| 110 | + text_bytes += len(text.encode("utf-8", errors="strict")) |
| 111 | + if text_bytes > MAX_XML_TEXT_BYTES: |
| 112 | + raise _fail("xml_structure_over_limit") |
| 113 | + stack.extend((child, depth + 1) for child in reversed(list(element))) |
| 114 | + return root |
| 115 | + |
| 116 | + |
| 117 | +def classify_feed_payload(feed_id: str, feed_url: str, payload: bytes) -> dict[str, object]: |
| 118 | + if type(payload) is not bytes: |
| 119 | + raise _fail("feed_payload_invalid") |
| 120 | + try: |
| 121 | + root = _parse_bounded_xml(payload) |
| 122 | + except FetchAcceptanceError as error: |
| 123 | + return _feed_result(feed_id, feed_url, "unknown", 0, 0, "failed", error.code) |
90 | 124 | if root.tag == "rss": |
91 | 125 | if root.attrib.get("version") not in {"2.0"}: |
92 | 126 | return _feed_result(feed_id, feed_url, "unknown", 0, 0, "failed", "rss_schema_unsupported") |
@@ -120,9 +154,15 @@ def _validate_feed_result(value: object) -> dict[str, object]: |
120 | 154 | result = _feed_result(value["feed_id"], value["feed_url"], value["kind"], value["accepted_row_count"], value["rejected_row_count"], value["state"], value["error_code"]) |
121 | 155 | if result != dict(value): |
122 | 156 | raise _fail("feed_result_noncanonical") |
123 | | - if result["state"] == "accepted" and (result["accepted_row_count"] <= 0 or result["rejected_row_count"] != 0 or result["error_code"] is not None): |
| 157 | + state = result["state"] |
| 158 | + accepted_rows = result["accepted_row_count"] |
| 159 | + rejected_rows = result["rejected_row_count"] |
| 160 | + error_code = result["error_code"] |
| 161 | + if state == "accepted" and (accepted_rows <= 0 or rejected_rows != 0 or error_code is not None): |
| 162 | + raise _fail("feed_result_invalid") |
| 163 | + if state in {"failed", "stale", "missing"} and (accepted_rows != 0 or rejected_rows != 0 or not error_code): |
124 | 164 | raise _fail("feed_result_invalid") |
125 | | - if result["state"] == "quarantined" and not result["error_code"]: |
| 165 | + if state == "quarantined" and not error_code: |
126 | 166 | raise _fail("feed_result_invalid") |
127 | 167 | return result |
128 | 168 |
|
|
0 commit comments