588 lines
20 KiB
Python
588 lines
20 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
from io import BytesIO
|
||
|
|
from urllib.parse import parse_qs, urlsplit
|
||
|
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||
|
|
|
||
|
|
import jwt
|
||
|
|
import pytest
|
||
|
|
from sqlalchemy import create_engine
|
||
|
|
from sqlalchemy.orm import sessionmaker
|
||
|
|
from sqlalchemy.pool import StaticPool
|
||
|
|
|
||
|
|
from app.api.deps import CurrentUserContext
|
||
|
|
from app.db.base import Base
|
||
|
|
from app.models.knowledge_security import KnowledgeOnlyOfficeSession
|
||
|
|
from app.models.tenant import Tenant
|
||
|
|
from app.services import knowledge_onlyoffice_security as security_module
|
||
|
|
from app.services.knowledge import KnowledgeService
|
||
|
|
from app.services.knowledge_onlyoffice_callback import (
|
||
|
|
handle_onlyoffice_callback,
|
||
|
|
resolve_onlyoffice_content,
|
||
|
|
)
|
||
|
|
from app.services.knowledge_onlyoffice_security import (
|
||
|
|
ONLYOFFICE_TOKEN_AUDIENCE,
|
||
|
|
KnowledgeOnlyOfficeSessionService,
|
||
|
|
OnlyOfficeReplayError,
|
||
|
|
OnlyOfficeSecurityError,
|
||
|
|
download_onlyoffice_document,
|
||
|
|
)
|
||
|
|
from app.services.knowledge_rag import KnowledgeRagService
|
||
|
|
from app.services.knowledge_tenant_scope import PLATFORM_KNOWLEDGE_SCOPE
|
||
|
|
from app.services.settings import OnlyOfficeRuntimeConfig
|
||
|
|
|
||
|
|
JWT_SECRET = "test-onlyoffice-security-secret-32bytes"
|
||
|
|
|
||
|
|
|
||
|
|
def _docx_bytes(text: str) -> bytes:
|
||
|
|
stream = BytesIO()
|
||
|
|
with ZipFile(stream, mode="w", compression=ZIP_DEFLATED) as archive:
|
||
|
|
archive.writestr(
|
||
|
|
"[Content_Types].xml",
|
||
|
|
"""<?xml version="1.0" encoding="UTF-8"?>
|
||
|
|
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||
|
|
<Default Extension="xml" ContentType="application/xml" />
|
||
|
|
</Types>""",
|
||
|
|
)
|
||
|
|
archive.writestr(
|
||
|
|
"word/document.xml",
|
||
|
|
"""<?xml version="1.0" encoding="UTF-8"?>
|
||
|
|
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||
|
|
<w:body><w:p><w:r><w:t>"""
|
||
|
|
+ text
|
||
|
|
+ """</w:t></w:r></w:p></w:body>
|
||
|
|
</w:document>""",
|
||
|
|
)
|
||
|
|
return stream.getvalue()
|
||
|
|
|
||
|
|
|
||
|
|
def _user(tenant_id: str, *, admin: bool = True) -> CurrentUserContext:
|
||
|
|
return CurrentUserContext(
|
||
|
|
username=f"user-{tenant_id}",
|
||
|
|
name=f"用户 {tenant_id}",
|
||
|
|
role_codes=["manager"] if admin else ["employee"],
|
||
|
|
is_admin=admin,
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _factory():
|
||
|
|
assert "tenants" in Base.metadata.tables
|
||
|
|
engine = create_engine(
|
||
|
|
"sqlite+pysqlite:///:memory:",
|
||
|
|
connect_args={"check_same_thread": False},
|
||
|
|
poolclass=StaticPool,
|
||
|
|
)
|
||
|
|
Tenant.__table__.create(bind=engine)
|
||
|
|
KnowledgeOnlyOfficeSession.__table__.create(bind=engine)
|
||
|
|
factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||
|
|
with factory() as db:
|
||
|
|
db.add_all(
|
||
|
|
[
|
||
|
|
Tenant(tenant_id="tenant-a", tenant_code="A", name="A", status="active"),
|
||
|
|
Tenant(tenant_id="tenant-b", tenant_code="B", name="B", status="active"),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
db.commit()
|
||
|
|
return factory
|
||
|
|
|
||
|
|
|
||
|
|
def _configure_onlyoffice(monkeypatch) -> OnlyOfficeRuntimeConfig:
|
||
|
|
runtime = OnlyOfficeRuntimeConfig(
|
||
|
|
enabled=True,
|
||
|
|
public_url="https://docs.example.com",
|
||
|
|
backend_url="https://app.example.com",
|
||
|
|
jwt_secret=JWT_SECRET,
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"app.services.knowledge_onlyoffice.resolve_onlyoffice_settings",
|
||
|
|
lambda *_args, **_kwargs: runtime,
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
security_module,
|
||
|
|
"resolve_onlyoffice_settings",
|
||
|
|
lambda *_args, **_kwargs: runtime,
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
KnowledgeRagService,
|
||
|
|
"get_document_status_map",
|
||
|
|
lambda _self, _document_ids: {},
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(KnowledgeRagService, "delete_document", lambda *_args: None)
|
||
|
|
return runtime
|
||
|
|
|
||
|
|
|
||
|
|
def _token_from_url(url: str, parameter: str) -> str:
|
||
|
|
return parse_qs(urlsplit(url).query)[parameter][0]
|
||
|
|
|
||
|
|
|
||
|
|
def test_onlyoffice_tokens_bind_tenant_resource_key_version_and_audience(
|
||
|
|
tmp_path,
|
||
|
|
monkeypatch,
|
||
|
|
) -> None:
|
||
|
|
_configure_onlyoffice(monkeypatch)
|
||
|
|
factory = _factory()
|
||
|
|
with factory() as db:
|
||
|
|
service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a")
|
||
|
|
uploaded = service.upload_document(
|
||
|
|
"制度政策",
|
||
|
|
"制度.docx",
|
||
|
|
_docx_bytes("version one"),
|
||
|
|
_user("tenant-a"),
|
||
|
|
)
|
||
|
|
config = service.build_onlyoffice_config(uploaded.id, _user("tenant-a"))
|
||
|
|
content_token = _token_from_url(config.config["document"]["url"], "access_token")
|
||
|
|
callback_token = _token_from_url(
|
||
|
|
config.config["editorConfig"]["callbackUrl"],
|
||
|
|
"callback_token",
|
||
|
|
)
|
||
|
|
claims = jwt.decode(
|
||
|
|
content_token,
|
||
|
|
JWT_SECRET,
|
||
|
|
algorithms=["HS256"],
|
||
|
|
audience=ONLYOFFICE_TOKEN_AUDIENCE,
|
||
|
|
)
|
||
|
|
callback_claims = jwt.decode(
|
||
|
|
callback_token,
|
||
|
|
JWT_SECRET,
|
||
|
|
algorithms=["HS256"],
|
||
|
|
audience=ONLYOFFICE_TOKEN_AUDIENCE,
|
||
|
|
)
|
||
|
|
|
||
|
|
assert claims["tenant_id"] == "tenant-a"
|
||
|
|
assert claims["document_id"] == uploaded.id
|
||
|
|
assert claims["document_key"] == config.config["document"]["key"]
|
||
|
|
assert claims["document_version"] == 1
|
||
|
|
assert claims["editable"] is False
|
||
|
|
assert callback_claims["jti"] == claims["jti"]
|
||
|
|
assert callback_claims["exp"] - claims["exp"] >= 3 * 60 * 60
|
||
|
|
row = db.get(KnowledgeOnlyOfficeSession, claims["jti"])
|
||
|
|
assert row is not None and row.tenant_id == "tenant-a" and row.status == "active"
|
||
|
|
content_path, _, _ = resolve_onlyoffice_content(
|
||
|
|
db=db,
|
||
|
|
storage_root=tmp_path,
|
||
|
|
document_id=uploaded.id,
|
||
|
|
access_token=content_token,
|
||
|
|
)
|
||
|
|
assert content_path.is_relative_to(
|
||
|
|
tmp_path / "knowledge" / "tenants" / "tenant-a"
|
||
|
|
)
|
||
|
|
with ZipFile(content_path) as archive:
|
||
|
|
assert b"version one" in archive.read("word/document.xml")
|
||
|
|
|
||
|
|
tampered_claims = dict(claims)
|
||
|
|
tampered_claims["tenant_id"] = "tenant-b"
|
||
|
|
tampered_token = jwt.encode(tampered_claims, JWT_SECRET, algorithm="HS256")
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="不匹配"):
|
||
|
|
KnowledgeOnlyOfficeSessionService(db).validate_content(
|
||
|
|
document_id=uploaded.id,
|
||
|
|
token=tampered_token,
|
||
|
|
)
|
||
|
|
assert callback_token
|
||
|
|
|
||
|
|
|
||
|
|
def test_platform_document_session_is_tenant_bound_and_strictly_read_only(
|
||
|
|
tmp_path,
|
||
|
|
monkeypatch,
|
||
|
|
) -> None:
|
||
|
|
_configure_onlyoffice(monkeypatch)
|
||
|
|
document_id = "platform-doc"
|
||
|
|
content = _docx_bytes("platform policy")
|
||
|
|
filename = "平台制度.docx"
|
||
|
|
stored_name = f"{document_id}__{filename}"
|
||
|
|
platform_root = tmp_path / "knowledge" / "platform"
|
||
|
|
folder_root = platform_root / "制度政策"
|
||
|
|
folder_root.mkdir(parents=True)
|
||
|
|
(folder_root / stored_name).write_bytes(content)
|
||
|
|
(platform_root / ".index.json").write_text(
|
||
|
|
json.dumps(
|
||
|
|
{
|
||
|
|
"version": 1,
|
||
|
|
"documents": [
|
||
|
|
{
|
||
|
|
"id": document_id,
|
||
|
|
"folder": "制度政策",
|
||
|
|
"original_name": filename,
|
||
|
|
"stored_name": stored_name,
|
||
|
|
"mime_type": (
|
||
|
|
"application/vnd.openxmlformats-officedocument."
|
||
|
|
"wordprocessingml.document"
|
||
|
|
),
|
||
|
|
"extension": "docx",
|
||
|
|
"size_bytes": len(content),
|
||
|
|
"sha256": "platform-checksum",
|
||
|
|
"created_at": "2026-07-17T00:00:00+00:00",
|
||
|
|
"updated_at": "2026-07-17T00:00:00+00:00",
|
||
|
|
"uploaded_by": "平台",
|
||
|
|
"version_number": 1,
|
||
|
|
"ingest_status": 1,
|
||
|
|
}
|
||
|
|
],
|
||
|
|
},
|
||
|
|
ensure_ascii=False,
|
||
|
|
),
|
||
|
|
encoding="utf-8",
|
||
|
|
)
|
||
|
|
factory = _factory()
|
||
|
|
with factory() as db:
|
||
|
|
service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a")
|
||
|
|
config = service.build_onlyoffice_config(document_id, _user("tenant-a"))
|
||
|
|
content_token = _token_from_url(config.config["document"]["url"], "access_token")
|
||
|
|
claims = jwt.decode(
|
||
|
|
content_token,
|
||
|
|
JWT_SECRET,
|
||
|
|
algorithms=["HS256"],
|
||
|
|
audience=ONLYOFFICE_TOKEN_AUDIENCE,
|
||
|
|
)
|
||
|
|
assert claims["tenant_id"] == "tenant-a"
|
||
|
|
assert claims["resource_scope"] == PLATFORM_KNOWLEDGE_SCOPE
|
||
|
|
assert claims["editable"] is False
|
||
|
|
resolved, _, _ = resolve_onlyoffice_content(
|
||
|
|
db=db,
|
||
|
|
storage_root=tmp_path,
|
||
|
|
document_id=document_id,
|
||
|
|
access_token=content_token,
|
||
|
|
)
|
||
|
|
assert resolved.read_bytes() == content
|
||
|
|
assert resolved.is_relative_to(platform_root)
|
||
|
|
with pytest.raises(ValueError, match="只读"):
|
||
|
|
service.build_onlyoffice_config(
|
||
|
|
document_id,
|
||
|
|
_user("tenant-a"),
|
||
|
|
editable=True,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_view_session_never_writes_and_wrong_key_does_not_claim_session(
|
||
|
|
tmp_path,
|
||
|
|
monkeypatch,
|
||
|
|
) -> None:
|
||
|
|
_configure_onlyoffice(monkeypatch)
|
||
|
|
factory = _factory()
|
||
|
|
with factory() as db:
|
||
|
|
service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a")
|
||
|
|
uploaded = service.upload_document(
|
||
|
|
"制度政策",
|
||
|
|
"制度.docx",
|
||
|
|
_docx_bytes("original"),
|
||
|
|
_user("tenant-a"),
|
||
|
|
)
|
||
|
|
view_config = service.build_onlyoffice_config(uploaded.id, _user("tenant-a"))
|
||
|
|
view_token = _token_from_url(
|
||
|
|
view_config.config["editorConfig"]["callbackUrl"],
|
||
|
|
"callback_token",
|
||
|
|
)
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="只读"):
|
||
|
|
handle_onlyoffice_callback(
|
||
|
|
db=db,
|
||
|
|
storage_root=tmp_path,
|
||
|
|
document_id=uploaded.id,
|
||
|
|
callback_token=view_token,
|
||
|
|
payload={
|
||
|
|
"status": 2,
|
||
|
|
"key": view_config.config["document"]["key"],
|
||
|
|
"url": "https://docs.example.com/download/view",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
edit_config = service.build_onlyoffice_config(
|
||
|
|
uploaded.id,
|
||
|
|
_user("tenant-a"),
|
||
|
|
editable=True,
|
||
|
|
)
|
||
|
|
edit_token = _token_from_url(
|
||
|
|
edit_config.config["editorConfig"]["callbackUrl"],
|
||
|
|
"callback_token",
|
||
|
|
)
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="key"):
|
||
|
|
handle_onlyoffice_callback(
|
||
|
|
db=db,
|
||
|
|
storage_root=tmp_path,
|
||
|
|
document_id=uploaded.id,
|
||
|
|
callback_token=edit_token,
|
||
|
|
payload={
|
||
|
|
"status": 2,
|
||
|
|
"key": "wrong-key",
|
||
|
|
"url": "https://docs.example.com/download/edit",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
edit_claims = jwt.decode(
|
||
|
|
edit_token,
|
||
|
|
JWT_SECRET,
|
||
|
|
algorithms=["HS256"],
|
||
|
|
audience=ONLYOFFICE_TOKEN_AUDIENCE,
|
||
|
|
)
|
||
|
|
assert db.get(KnowledgeOnlyOfficeSession, edit_claims["jti"]).status == "active"
|
||
|
|
assert service.get_document_entry(uploaded.id)["version_number"] == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_edit_callback_is_one_time_and_replay_is_rejected(tmp_path, monkeypatch) -> None:
|
||
|
|
_configure_onlyoffice(monkeypatch)
|
||
|
|
replacement = _docx_bytes("replacement")
|
||
|
|
monkeypatch.setattr(
|
||
|
|
"app.services.knowledge_onlyoffice_callback.download_onlyoffice_document",
|
||
|
|
lambda _url, *, expected_filename: replacement,
|
||
|
|
)
|
||
|
|
factory = _factory()
|
||
|
|
with factory() as db:
|
||
|
|
service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a")
|
||
|
|
uploaded = service.upload_document(
|
||
|
|
"制度政策",
|
||
|
|
"制度.docx",
|
||
|
|
_docx_bytes("original"),
|
||
|
|
_user("tenant-a"),
|
||
|
|
)
|
||
|
|
config = service.build_onlyoffice_config(
|
||
|
|
uploaded.id,
|
||
|
|
_user("tenant-a"),
|
||
|
|
editable=True,
|
||
|
|
)
|
||
|
|
callback_token = _token_from_url(
|
||
|
|
config.config["editorConfig"]["callbackUrl"],
|
||
|
|
"callback_token",
|
||
|
|
)
|
||
|
|
payload = {
|
||
|
|
"status": 2,
|
||
|
|
"key": config.config["document"]["key"],
|
||
|
|
"url": "https://docs.example.com/download/final",
|
||
|
|
"users": ["editor"],
|
||
|
|
}
|
||
|
|
handle_onlyoffice_callback(
|
||
|
|
db=db,
|
||
|
|
storage_root=tmp_path,
|
||
|
|
document_id=uploaded.id,
|
||
|
|
callback_token=callback_token,
|
||
|
|
payload=payload,
|
||
|
|
)
|
||
|
|
|
||
|
|
claims = jwt.decode(
|
||
|
|
callback_token,
|
||
|
|
JWT_SECRET,
|
||
|
|
algorithms=["HS256"],
|
||
|
|
audience=ONLYOFFICE_TOKEN_AUDIENCE,
|
||
|
|
)
|
||
|
|
assert db.get(KnowledgeOnlyOfficeSession, claims["jti"]).status == "consumed"
|
||
|
|
assert service.get_document_entry(uploaded.id)["version_number"] == 2
|
||
|
|
assert service.get_document_content(uploaded.id)[0].read_bytes() == replacement
|
||
|
|
with pytest.raises(OnlyOfficeReplayError):
|
||
|
|
handle_onlyoffice_callback(
|
||
|
|
db=db,
|
||
|
|
storage_root=tmp_path,
|
||
|
|
document_id=uploaded.id,
|
||
|
|
callback_token=callback_token,
|
||
|
|
payload=payload,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_expired_or_stale_version_session_is_rejected(tmp_path, monkeypatch) -> None:
|
||
|
|
_configure_onlyoffice(monkeypatch)
|
||
|
|
factory = _factory()
|
||
|
|
with factory() as db:
|
||
|
|
service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a")
|
||
|
|
uploaded = service.upload_document(
|
||
|
|
"制度政策",
|
||
|
|
"制度.docx",
|
||
|
|
_docx_bytes("original"),
|
||
|
|
_user("tenant-a"),
|
||
|
|
)
|
||
|
|
config = service.build_onlyoffice_config(uploaded.id, _user("tenant-a"))
|
||
|
|
content_token = _token_from_url(config.config["document"]["url"], "access_token")
|
||
|
|
claims = jwt.decode(
|
||
|
|
content_token,
|
||
|
|
JWT_SECRET,
|
||
|
|
algorithms=["HS256"],
|
||
|
|
audience=ONLYOFFICE_TOKEN_AUDIENCE,
|
||
|
|
)
|
||
|
|
row = db.get(KnowledgeOnlyOfficeSession, claims["jti"])
|
||
|
|
row.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
||
|
|
db.commit()
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError):
|
||
|
|
resolve_onlyoffice_content(
|
||
|
|
db=db,
|
||
|
|
storage_root=tmp_path,
|
||
|
|
document_id=uploaded.id,
|
||
|
|
access_token=content_token,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_callback_ssrf_attempt_fails_session_without_overwriting_document(
|
||
|
|
tmp_path,
|
||
|
|
monkeypatch,
|
||
|
|
) -> None:
|
||
|
|
_configure_onlyoffice(monkeypatch)
|
||
|
|
factory = _factory()
|
||
|
|
with factory() as db:
|
||
|
|
service = KnowledgeService(storage_root=tmp_path, db=db, tenant_id="tenant-a")
|
||
|
|
original = _docx_bytes("original")
|
||
|
|
uploaded = service.upload_document(
|
||
|
|
"制度政策",
|
||
|
|
"制度.docx",
|
||
|
|
original,
|
||
|
|
_user("tenant-a"),
|
||
|
|
)
|
||
|
|
config = service.build_onlyoffice_config(
|
||
|
|
uploaded.id,
|
||
|
|
_user("tenant-a"),
|
||
|
|
editable=True,
|
||
|
|
)
|
||
|
|
callback_token = _token_from_url(
|
||
|
|
config.config["editorConfig"]["callbackUrl"],
|
||
|
|
"callback_token",
|
||
|
|
)
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="不属于"):
|
||
|
|
handle_onlyoffice_callback(
|
||
|
|
db=db,
|
||
|
|
storage_root=tmp_path,
|
||
|
|
document_id=uploaded.id,
|
||
|
|
callback_token=callback_token,
|
||
|
|
payload={
|
||
|
|
"status": 2,
|
||
|
|
"key": config.config["document"]["key"],
|
||
|
|
"url": "https://evil.example/internal-metadata",
|
||
|
|
},
|
||
|
|
)
|
||
|
|
claims = jwt.decode(
|
||
|
|
callback_token,
|
||
|
|
JWT_SECRET,
|
||
|
|
algorithms=["HS256"],
|
||
|
|
audience=ONLYOFFICE_TOKEN_AUDIENCE,
|
||
|
|
)
|
||
|
|
assert db.get(KnowledgeOnlyOfficeSession, claims["jti"]).status == "failed"
|
||
|
|
assert service.get_document_content(uploaded.id)[0].read_bytes() == original
|
||
|
|
assert service.get_document_entry(uploaded.id)["version_number"] == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_download_target_rejects_wrong_origin_private_dns_and_redirects(monkeypatch) -> None:
|
||
|
|
_configure_onlyoffice(monkeypatch)
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="不属于"):
|
||
|
|
security_module._validate_download_target("https://evil.example/download")
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
security_module.socket,
|
||
|
|
"getaddrinfo",
|
||
|
|
lambda *_args, **_kwargs: [
|
||
|
|
(2, 1, 6, "", ("127.0.0.1", 443)),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="非公网"):
|
||
|
|
security_module._validate_download_target("https://docs.example.com/download")
|
||
|
|
|
||
|
|
monkeypatch.setattr(
|
||
|
|
security_module.socket,
|
||
|
|
"getaddrinfo",
|
||
|
|
lambda *_args, **_kwargs: [
|
||
|
|
(2, 1, 6, "", ("8.8.8.8", 443)),
|
||
|
|
],
|
||
|
|
)
|
||
|
|
|
||
|
|
class RedirectResponse:
|
||
|
|
status = 302
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def getheader(_name):
|
||
|
|
return None
|
||
|
|
|
||
|
|
class FakeConnection:
|
||
|
|
def request(self, *_args, **_kwargs) -> None:
|
||
|
|
pass
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def getresponse():
|
||
|
|
return RedirectResponse()
|
||
|
|
|
||
|
|
def close(self) -> None:
|
||
|
|
pass
|
||
|
|
|
||
|
|
pinned: list[str] = []
|
||
|
|
|
||
|
|
def fake_open(_parsed, resolved_ip):
|
||
|
|
pinned.append(resolved_ip)
|
||
|
|
return FakeConnection()
|
||
|
|
|
||
|
|
monkeypatch.setattr(security_module, "_open_pinned_connection", fake_open)
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="状态码 302"):
|
||
|
|
download_onlyoffice_document(
|
||
|
|
"https://docs.example.com/download",
|
||
|
|
expected_filename="制度.docx",
|
||
|
|
)
|
||
|
|
assert pinned == ["8.8.8.8"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_download_enforces_mime_size_and_ooxml_structure(monkeypatch) -> None:
|
||
|
|
_configure_onlyoffice(monkeypatch)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
security_module.socket,
|
||
|
|
"getaddrinfo",
|
||
|
|
lambda *_args, **_kwargs: [(2, 1, 6, "", ("8.8.8.8", 443))],
|
||
|
|
)
|
||
|
|
|
||
|
|
class Response:
|
||
|
|
status = 200
|
||
|
|
|
||
|
|
def __init__(self, body: bytes, content_type: str, declared_size: int | None = None):
|
||
|
|
self.body = body
|
||
|
|
self.content_type = content_type
|
||
|
|
self.declared_size = declared_size
|
||
|
|
|
||
|
|
def getheader(self, name):
|
||
|
|
if name == "Content-Type":
|
||
|
|
return self.content_type
|
||
|
|
if name == "Content-Length":
|
||
|
|
return None if self.declared_size is None else str(self.declared_size)
|
||
|
|
return None
|
||
|
|
|
||
|
|
def read(self, limit):
|
||
|
|
return self.body[:limit]
|
||
|
|
|
||
|
|
class Connection:
|
||
|
|
def __init__(self, response):
|
||
|
|
self.response = response
|
||
|
|
|
||
|
|
def request(self, *_args, **_kwargs) -> None:
|
||
|
|
pass
|
||
|
|
|
||
|
|
def getresponse(self):
|
||
|
|
return self.response
|
||
|
|
|
||
|
|
def close(self) -> None:
|
||
|
|
pass
|
||
|
|
|
||
|
|
response = Response(
|
||
|
|
_docx_bytes("safe"),
|
||
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
|
|
)
|
||
|
|
monkeypatch.setattr(
|
||
|
|
security_module,
|
||
|
|
"_open_pinned_connection",
|
||
|
|
lambda *_args: Connection(response),
|
||
|
|
)
|
||
|
|
downloaded = download_onlyoffice_document(
|
||
|
|
"https://docs.example.com/download",
|
||
|
|
expected_filename="制度.docx",
|
||
|
|
)
|
||
|
|
with ZipFile(BytesIO(downloaded)) as archive:
|
||
|
|
assert b"safe" in archive.read("word/document.xml")
|
||
|
|
|
||
|
|
response.content_type = "text/html"
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="MIME"):
|
||
|
|
download_onlyoffice_document(
|
||
|
|
"https://docs.example.com/download",
|
||
|
|
expected_filename="制度.docx",
|
||
|
|
)
|
||
|
|
response.content_type = "application/octet-stream"
|
||
|
|
response.declared_size = 200 * 1024 * 1024
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="大小"):
|
||
|
|
download_onlyoffice_document(
|
||
|
|
"https://docs.example.com/download",
|
||
|
|
expected_filename="制度.docx",
|
||
|
|
)
|
||
|
|
response.declared_size = None
|
||
|
|
response.body = b"not-a-zip"
|
||
|
|
with pytest.raises(OnlyOfficeSecurityError, match="OOXML"):
|
||
|
|
download_onlyoffice_document(
|
||
|
|
"https://docs.example.com/download",
|
||
|
|
expected_filename="制度.docx",
|
||
|
|
)
|