mirror of
https://github.com/sligter/LandPPT.git
synced 2026-08-28 23:31:06 +08:00
d52b969323
Audit of the outline- and slide-generation paths found four systemic defects.
All are fixed here, with 244 regression tests added across 9 new test files.
Failures were being reported as successes. Every fallback path persisted
fabricated or broken content, marked the stage completed, billed the user and
returned a success message:
- Add OutlineRepairFailedError; the repair loop now raises instead of returning
an outline that failed all 10 attempts.
- Drop the 3-page placeholder outline that replaced a failed generation.
- Track failed slides separately so they are not billed, not marked completed,
and not skipped as "already generated" on a re-run.
- Stop decoding binary uploads as latin-1, which turned PDFs into mojibake
outlines that were reported as successful.
Cancellation, reset and slide locks did nothing:
- Forward _is_slides_generation_cancelled through EnhancedPPTService, which has
no __getattr__, so the stop button was a silent no-op for the whole run.
- Catch CancelledError when awaiting the cancelled lock renewer; it is a
BaseException, so the distributed lock was never released.
- Add clear_project_outline/clear_project_slides; save_project_outline(None)
and save_project_slides("", []) were both no-ops, so stage reset kept the
old data and replayed it as a fresh result.
- Persist slide locks in slide_metadata and honour them in batch regeneration.
HTML validation was inverted. libxml2 only knows HTML 4.0, so strict parsing
rejected <header>/<section>/<svg> while accepting truncated documents:
- Add html_structure.py, an HTML5-aware structural check built on html.parser.
- Re-validate after auto-repair rather than treating "the string changed" as
success, which made the retry budget unreachable.
- Prefer the last complete document when extracting HTML, so repair responses
that echo the original no longer return the unrepaired input.
The streaming think-tag filter lost or leaked content when a marker was split
across chunks; rewrite it to buffer partial markers.
Also fixed:
- Confine client-supplied file paths to the upload roots (arbitrary file read).
- Escape model-generated content before it reaches innerHTML (stored XSS).
- Enforce ownership on the slide cancel endpoints and validate stage status.
- Give the fixed page-count mode a real branch; it behaved as ai_decide and
overwrote the user's setting.
- Stop citation markers such as "[1]" from hijacking outline JSON extraction.
- Bill only slides that were actually persisted, and price the streaming and
non-streaming outline routes identically.
- Cancel orphaned generation tasks when an SSE client disconnects.
- Release stale "running" tasks found in the database, not just in Valkey.
- Replace a 3s unconditional poll (which updated absent DOM nodes) with a
stoppable 15s poll, and add an AbortController to prevent concurrent streams.
- Implement restore_project_version, which raised AttributeError on every call.
- Declare lxml, which was imported directly but only present transitively.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
5.9 KiB
Python
161 lines
5.9 KiB
Python
"""Security regression tests for the outline/PPT generation pipelines."""
|
|
|
|
import re
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from landppt.core.file_access import (
|
|
UnsafeFilePathError,
|
|
is_within_allowed_roots,
|
|
sanitize_path_component,
|
|
validate_client_file_path,
|
|
)
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
TODO_BOARD_JS = (
|
|
REPO
|
|
/ "src" / "landppt" / "web" / "templates" / "components" / "project"
|
|
/ "todo_board" / "extra_js_1.html"
|
|
)
|
|
|
|
|
|
class TestClientFilePathContainment:
|
|
def test_accepts_file_inside_temp_dir(self):
|
|
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as handle:
|
|
handle.write(b"hello")
|
|
path = handle.name
|
|
try:
|
|
assert validate_client_file_path(path).exists()
|
|
finally:
|
|
Path(path).unlink(missing_ok=True)
|
|
|
|
@pytest.mark.parametrize(
|
|
"candidate",
|
|
[
|
|
"",
|
|
" ",
|
|
None,
|
|
],
|
|
)
|
|
def test_rejects_empty_paths(self, candidate):
|
|
with pytest.raises(UnsafeFilePathError):
|
|
validate_client_file_path(candidate)
|
|
|
|
def test_rejects_project_dotenv(self):
|
|
"""The reported arbitrary-read vector: a path outside the upload roots."""
|
|
target = REPO / ".env"
|
|
with pytest.raises(UnsafeFilePathError):
|
|
validate_client_file_path(str(target))
|
|
|
|
def test_rejects_traversal_out_of_temp_root(self):
|
|
traversal = Path(tempfile.gettempdir()) / ".." / ".." / "windows" / "win.ini"
|
|
with pytest.raises(UnsafeFilePathError):
|
|
validate_client_file_path(str(traversal))
|
|
|
|
def test_rejects_source_tree_paths(self):
|
|
source_file = REPO / "src" / "landppt" / "core" / "config.py"
|
|
assert source_file.exists()
|
|
assert not is_within_allowed_roots(source_file)
|
|
with pytest.raises(UnsafeFilePathError):
|
|
validate_client_file_path(str(source_file))
|
|
|
|
def test_rejects_missing_file_inside_root(self):
|
|
missing = Path(tempfile.gettempdir()) / "landppt-does-not-exist-9f3a.txt"
|
|
with pytest.raises(UnsafeFilePathError):
|
|
validate_client_file_path(str(missing))
|
|
|
|
def test_null_byte_rejected(self):
|
|
with pytest.raises(UnsafeFilePathError):
|
|
validate_client_file_path("some\x00path.txt")
|
|
|
|
|
|
class TestPathComponentSanitizer:
|
|
@pytest.mark.parametrize(
|
|
"raw,forbidden",
|
|
[
|
|
("C/C++ 性能优化", "/"),
|
|
('report: "final"', ':'),
|
|
("a\\b", "\\"),
|
|
("what?", "?"),
|
|
("wild*card", "*"),
|
|
("pipe|it", "|"),
|
|
("less<greater>", "<"),
|
|
],
|
|
)
|
|
def test_strips_reserved_characters(self, raw, forbidden):
|
|
assert forbidden not in sanitize_path_component(raw)
|
|
|
|
def test_falls_back_when_everything_stripped(self):
|
|
assert sanitize_path_component("", fallback="topic") == "topic"
|
|
assert sanitize_path_component("...") == "file"
|
|
|
|
def test_respects_max_length(self):
|
|
assert len(sanitize_path_component("x" * 200, max_length=30)) == 30
|
|
|
|
|
|
class TestStageStatusValidation:
|
|
"""Read the source rather than importing: landppt_api pulls in optional deps."""
|
|
|
|
def test_endpoint_validates_status_against_a_whitelist(self):
|
|
source = (REPO / "src" / "landppt" / "api" / "landppt_api.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
assert "ALLOWED_STAGE_STATUSES = frozenset(" in source
|
|
assert "if status not in ALLOWED_STAGE_STATUSES" in source
|
|
|
|
whitelist = source[source.index("ALLOWED_STAGE_STATUSES = frozenset(") :][:400]
|
|
for expected in ("pending", "running", "completed", "failed", "cancelled"):
|
|
assert f'"{expected}"' in whitelist
|
|
|
|
def test_endpoint_validates_progress_range(self):
|
|
source = (REPO / "src" / "landppt" / "api" / "landppt_api.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
assert "Progress must be between 0 and 100" in source
|
|
|
|
|
|
class TestCancelEndpointOwnership:
|
|
def test_cancel_routes_load_the_project_first(self):
|
|
source = (
|
|
REPO / "src" / "landppt" / "web" / "route_modules" / "slide_routes.py"
|
|
).read_text(encoding="utf-8")
|
|
|
|
for route in ("/slides/cancel", "/slides/clear-cancel"):
|
|
marker = f'@router.post("/api/projects/{{project_id}}{route}")'
|
|
assert marker in source, f"route {route} not found"
|
|
body = source[source.index(marker) :][:1200]
|
|
assert "get_project(project_id, user_id=user.id)" in body, (
|
|
f"{route} must verify project ownership"
|
|
)
|
|
assert 'status_code=404' in body
|
|
|
|
|
|
class TestOutlineRenderingEscapesModelContent:
|
|
def test_escape_helper_exists(self):
|
|
source = TODO_BOARD_JS.read_text(encoding="utf-8")
|
|
assert "function escapeHtml(value)" in source
|
|
|
|
def test_no_unescaped_model_fields_reach_innerhtml(self):
|
|
source = TODO_BOARD_JS.read_text(encoding="utf-8")
|
|
risky = re.compile(
|
|
r"\$\{\s*(slide\.(title|subtitle|content|slide_type|description)"
|
|
r"|outline\.title|lastOutlineErrorMessage)\s*[}|]"
|
|
)
|
|
offenders = []
|
|
for index, line in enumerate(source.splitlines(), start=1):
|
|
if not risky.search(line) or "escapeHtml" in line:
|
|
continue
|
|
# Only markup-building lines can inject; plain-text template
|
|
# literals (prompt strings, object properties) cannot.
|
|
if "<" in line:
|
|
offenders.append((index, line.strip()))
|
|
assert not offenders, f"unescaped model content: {offenders}"
|
|
|
|
def test_textarea_bodies_are_escaped(self):
|
|
source = TODO_BOARD_JS.read_text(encoding="utf-8")
|
|
# A raw `</textarea>` inside model content would break out of the editor.
|
|
assert ">${escapeHtml(outlineContent)}</textarea>" in source
|
|
assert ">${escapeHtml(slide.description || '')}</textarea>" in source
|