router: Multipart-Parser auf email.parser.BytesParser umgestellt

- Robustes MIME-Parsing mit Standardbibliothek (email.parser.BytesParser)
- Handhabt quoted und unquoted Boundaries (Open-WebUI-Kompatibilität)
- Beliebiges Feldreihenfolge, zusätzliche Header, binäre Payloads
- Regressionstests: dev/test_multipart.py (8 Tests)
- Fix für Open-WebUI HTTP 400 bei POST /v1/audio/transcriptions
This commit is contained in:
Mikei386
2026-08-19 14:37:24 +02:00
parent 51ef07c874
commit e81f2ab515
2 changed files with 271 additions and 29 deletions
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""Regressionstests für den Multipart-Parser im Router.
Testet quoted/unquoted Boundaries, Open-WebUI-artige Requests,
beliebige Feldreihenfolge und binäre Payloads.
Aufruf: python3 dev/test_multipart.py
"""
import sys
from email.parser import BytesParser
from email.policy import compat32
def parse_multipart(data: bytes, content_type: str) -> tuple[bytes, str, dict]:
"""Identische Logik wie router/ai_profile_router.py::_parse_multipart."""
raw = (f"Content-Type: {content_type}\r\n\r\n").encode("utf-8") + data
msg = BytesParser(policy=compat32).parsebytes(raw)
if not msg.is_multipart():
raise ValueError("Kein multipart/form-data")
file_data = b""
filename = ""
fields = {}
for part in msg.get_payload():
disposition = part.get("Content-Disposition", "")
name = None
part_filename = None
for kv in disposition.split(";"):
kv = kv.strip()
if kv.startswith("name="):
name = kv[len("name="):].strip('"')
elif kv.startswith("filename="):
part_filename = kv[len("filename="):].strip('"')
if name is None:
continue
payload = part.get_payload(decode=True)
if payload is None:
payload = b""
if part_filename is not None:
file_data = payload
filename = part_filename or ""
else:
fields[name] = payload.decode("utf-8", errors="replace")
return file_data, filename, fields
def build(body_parts: list[tuple[str, str | bytes, str | None]],
boundary: str, quoted: bool = False) -> tuple[bytes, str]:
"""Baut einen Multipart-Body. body_parts: (name, value, filename)."""
b = f'"{boundary}"' if quoted else boundary
ct = f"multipart/form-data; boundary={b}"
parts = []
for name, value, filename in body_parts:
if isinstance(value, bytes):
header = (f'Content-Disposition: form-data; name="{name}"')
if filename:
header += f'; filename="{filename}"'
header += f'\r\nContent-Type: application/octet-stream\r\n\r\n'
parts.append(header.encode() + value + b"\r\n")
else:
parts.append(
f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'
.encode()
)
sep = f"--{boundary}\r\n".encode()
body = sep + sep.join(parts) + (f"--{boundary}--\r\n").encode()
return body, ct
def test_quoted_boundary():
"""Quoted boundary (boundary=\"...\")."""
boundary = "----WebKitFormBoundary7MA4YWxkTrZu0gW"
webm = b"\x1a\x45\xdf\xa3" + b"\x00\x01\x02\x03\xff\xfe\xfd" * 50
body, ct = build(
[("model", "whisper-1", None), ("file", webm, "t.webm")],
boundary, quoted=True,
)
fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch"
assert fn == "t.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
print(" quoted boundary: OK")
def test_unquoted_boundary():
"""Unquoted boundary (boundary=...)."""
boundary = "AaB03x"
body, ct = build(
[("file", b"RIFF", "a.wav"), ("language", "de", None)],
boundary, quoted=False,
)
fd, fn, fl = parse_multipart(body, ct)
assert fd == b"RIFF", "file_data mismatch"
assert fn == "a.wav", f"filename mismatch: {fn!r}"
assert fl["language"] == "de", f"language mismatch: {fl!r}"
print(" unquoted boundary: OK")
def test_openwebui_style():
"""Open-WebUI-artiger Request (quoted, extra Header, File vor model)."""
boundary = "----formdata-boundary"
webm = b"\x1a\x45\xdf\xa3" + b"\xde\xad\xbe\xef" * 30
ct = f'multipart/form-data; boundary="{boundary}"'
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="rec.webm"\r\n'
f"Content-Type: audio/webm; codecs=opus\r\n"
f"\r\n"
).encode() + webm + (
f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="model"\r\n'
f"\r\n"
f"whisper-1\r\n"
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="temperature"\r\n'
f"\r\n"
f"0.0\r\n"
f"--{boundary}--\r\n"
).encode()
fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch"
assert fn == "rec.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
assert fl["temperature"] == "0.0", f"temperature mismatch: {fl!r}"
print(" Open-WebUI-artig: OK")
def test_file_before_model():
"""File-Feld vor model-Feld."""
boundary = "boundary123"
body, ct = build(
[("file", b"DATA", "f.wav"), ("model", "whisper-1", None)],
boundary, quoted=False,
)
fd, fn, fl = parse_multipart(body, ct)
assert fd == b"DATA", "file_data mismatch"
assert fn == "f.wav", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
print(" File vor model: OK")
def test_file_after_model():
"""model-Feld vor File-Feld."""
boundary = "boundary456"
body, ct = build(
[("model", "whisper-1", None), ("file", b"DATA", "g.wav")],
boundary, quoted=False,
)
fd, fn, fl = parse_multipart(body, ct)
assert fd == b"DATA", "file_data mismatch"
assert fn == "g.wav", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
print(" File nach model: OK")
def test_webm_binary_payload():
"""WebM-Binärpayload (alle 256 Byte-Werte)."""
boundary = "boundary789"
webm = bytes(range(256)) * 10 # 2560 bytes
body, ct = build(
[("file", webm, "bin.webm")],
boundary, quoted=False,
)
fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, f"file_data mismatch ({len(fd)} vs {len(webm)})"
assert fn == "bin.webm", f"filename mismatch: {fn!r}"
print(" WebM-Binärpayload: OK")
def test_extra_headers_ignored():
"""Zusätzliche Multipart-Header werden ignoriert."""
boundary = "boundaryExtra"
webm = b"\x1a\x45\xdf\xa3" + b"\x00" * 100
ct = f'multipart/form-data; boundary="{boundary}"'
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="x.webm"\r\n'
f"Content-Type: audio/webm\r\n"
f"X-Custom-Header: ignored\r\n"
f"Content-Transfer-Encoding: binary\r\n"
f"\r\n"
).encode() + webm + (
f"\r\n--{boundary}\r\n"
f'Content-Disposition: form-data; name="model"\r\n'
f"\r\n"
f"whisper-1\r\n"
f"--{boundary}--\r\n"
).encode()
fd, fn, fl = parse_multipart(body, ct)
assert fd == webm, "file_data mismatch"
assert fn == "x.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
print(" Extra-Header ignoriert: OK")
def test_all_fields():
"""Alle unterstützten Felder (model, language, prompt, response_format, temperature)."""
boundary = "boundaryAll"
body, ct = build(
[
("file", b"AUDIO", "a.webm"),
("model", "whisper-1", None),
("language", "de", None),
("prompt", "Kontext", None),
("response_format", "verbose_json", None),
("temperature", "0.5", None),
],
boundary, quoted=True,
)
fd, fn, fl = parse_multipart(body, ct)
assert fd == b"AUDIO", "file_data mismatch"
assert fn == "a.webm", f"filename mismatch: {fn!r}"
assert fl["model"] == "whisper-1", f"model mismatch: {fl!r}"
assert fl["language"] == "de", f"language mismatch: {fl!r}"
assert fl["prompt"] == "Kontext", f"prompt mismatch: {fl!r}"
assert fl["response_format"] == "verbose_json", f"response_format mismatch: {fl!r}"
assert fl["temperature"] == "0.5", f"temperature mismatch: {fl!r}"
print(" Alle Felder: OK")
def main():
print("Multipart-Parser Regressionstests:")
test_quoted_boundary()
test_unquoted_boundary()
test_openwebui_style()
test_file_before_model()
test_file_after_model()
test_webm_binary_payload()
test_extra_headers_ignored()
test_all_fields()
print("\nAlle Multipart-Parser-Tests bestanden!")
if __name__ == "__main__":
main()
+37 -29
View File
@@ -45,6 +45,7 @@ Nur Python-Standardbibliothek. Logging nach stdout (journald).
from __future__ import annotations from __future__ import annotations
import base64 import base64
import email
import json import json
import logging import logging
import os import os
@@ -56,6 +57,8 @@ import threading
import time import time
import uuid import uuid
import http.client import http.client
from email.parser import BytesParser
from email.policy import compat32
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1127,42 +1130,47 @@ class Handler(BaseHTTPRequestHandler):
def _parse_multipart(self, data: bytes, content_type: str def _parse_multipart(self, data: bytes, content_type: str
) -> tuple[bytes, str, dict]: ) -> tuple[bytes, str, dict]:
"""Parst multipart/form-data. Liefert (file_data, filename, fields).""" """Parst multipart/form-data. Liefert (file_data, filename, fields).
boundary = None
for part in content_type.split(";"): Nutzt email.parser.BytesParser (Standardbibliothek) für robustes
part = part.strip() MIME-Parsing. Handhabt quoted und unquoted Boundaries, beliebige
if part.startswith("boundary="): Feldreihenfolge, zusätzliche Header und binäre Payloads.
boundary = part[len("boundary="):] """
break # MIME-Message aus rohen Bytes + Content-Type-Header bauen
if not boundary: raw = (f"Content-Type: {content_type}\r\n\r\n"
raise ValueError("Kein Boundary in Content-Type") ).encode("utf-8") + data
msg = BytesParser(policy=compat32).parsebytes(raw)
if not msg.is_multipart():
raise ValueError("Kein multipart/form-data")
boundary_bytes = boundary.encode("utf-8")
file_data = b"" file_data = b""
filename = "" filename = ""
fields = {} fields = {}
parts = data.split(b"--" + boundary_bytes) for part in msg.get_payload():
for part in parts: disposition = part.get("Content-Disposition", "")
if part in (b"", b"--", b"--\r\n", b"\r\n"): name = None
part_filename = None
for kv in disposition.split(";"):
kv = kv.strip()
if kv.startswith("name="):
name = kv[len("name="):].strip('"')
elif kv.startswith("filename="):
part_filename = kv[len("filename="):].strip('"')
if name is None:
continue continue
if b"\r\n\r\n" not in part:
continue
header_part, body_part = part.split(b"\r\n\r\n", 1)
if body_part.endswith(b"\r\n"):
body_part = body_part[:-2]
header_text = header_part.decode("utf-8", errors="replace") payload = part.get_payload(decode=True)
for line in header_text.split("\r\n"): if payload is None:
if "name=" in line and "filename=" in line: payload = b""
for kv in line.split(";"):
kv = kv.strip() if part_filename is not None:
if kv.startswith("filename="): # Dateifeld (binär, nicht dekodieren)
filename = kv[len("filename="):].strip('"') file_data = payload
file_data = body_part filename = part_filename or ""
elif "name=" in line: else:
name = line.split("name=")[1].strip().strip('"') # Textfeld
fields[name] = body_part.decode("utf-8", errors="replace") fields[name] = payload.decode("utf-8", errors="replace")
return file_data, filename, fields return file_data, filename, fields