fix(exporting): F01 路径完整验证前不登记清理目标,清理不跟随符号链接
- 根目录/用户目录/目标文件先通过 lstat 完整校验(拒绝符号链接与非目录), 校验失败时不删除任何磁盘文件(此前父目录为链接时会沿链接 unlink 同名文件) - 失败清理只处理本次登记的受控文件,并通过 O_NOFOLLOW 目录描述符删除; 目录被换成符号链接时拒绝删除,保留任务编号与路径的对应关系 - 测试覆盖:用户目录链接、目标链接、运行中目录被换成链接(受保护文件字节不变)、 失败后正常重试成功
This commit is contained in:
+68
-21
@@ -1,7 +1,9 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import stat
|
||||||
import warnings
|
import warnings
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from decimal import Decimal, InvalidOperation
|
from decimal import Decimal, InvalidOperation
|
||||||
@@ -28,6 +30,58 @@ FORMULA_VERSION = "3"
|
|||||||
MAX_ROWS = 100_000
|
MAX_ROWS = 100_000
|
||||||
MAX_GROUPS = 200
|
MAX_GROUPS = 200
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_export_directory(path, description):
|
||||||
|
"""导出目录必须不存在或为真实目录;符号链接一律拒绝并保持文件原样。"""
|
||||||
|
try:
|
||||||
|
info = os.lstat(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
path.mkdir(parents=True, mode=0o700)
|
||||||
|
info = os.lstat(path)
|
||||||
|
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
||||||
|
raise ValueError(f"{description}必须是真实目录,不能是符号链接")
|
||||||
|
os.chmod(path, 0o700)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_export_target(user_dir, job):
|
||||||
|
"""目标文件若已存在必须是普通文件;符号链接或其他类型一律拒绝覆盖。"""
|
||||||
|
target = user_dir / f"{job.pk}.xlsx"
|
||||||
|
try:
|
||||||
|
info = os.lstat(target)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return target
|
||||||
|
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
||||||
|
raise ValueError("公式导出目标路径已存在且不是普通文件,已拒绝生成")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _unlink_created_file(path):
|
||||||
|
"""只通过不跟随链接的目录句柄删除受控目录内的普通文件。
|
||||||
|
|
||||||
|
返回 "removed"(已删除)、"missing"(本就不存在)或 "blocked"(无法安全删除,
|
||||||
|
例如目录已被换成符号链接或权限不足),调用方据此保留失败任务与磁盘的一致性。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
dir_fd = os.open(path.parent, os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
except OSError:
|
||||||
|
return "blocked"
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
info = os.lstat(path.name, dir_fd=dir_fd)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "missing"
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
return "blocked"
|
||||||
|
os.unlink(path.name, dir_fd=dir_fd)
|
||||||
|
return "removed"
|
||||||
|
except OSError:
|
||||||
|
return "blocked"
|
||||||
|
finally:
|
||||||
|
os.close(dir_fd)
|
||||||
|
|
||||||
|
|
||||||
def _excel_number(value):
|
def _excel_number(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -594,41 +648,34 @@ def build_formula_workbook(ds, member, export_context, output):
|
|||||||
|
|
||||||
def process_formula_export(export_id):
|
def process_formula_export(export_id):
|
||||||
job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id)
|
job = FormulaExport.objects.select_related("dataset", "user").get(pk=export_id)
|
||||||
temporary = None
|
|
||||||
target = None
|
target = None
|
||||||
|
created = []
|
||||||
try:
|
try:
|
||||||
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
|
||||||
if root.is_symlink():
|
|
||||||
raise ValueError("公式导出目录不能是符号链接")
|
|
||||||
root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
root.chmod(0o700)
|
|
||||||
target = root / str(job.user_id) / f"{job.pk}.xlsx"
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
||||||
if target.parent.is_symlink() or target.is_symlink():
|
|
||||||
raise ValueError("公式导出路径不能是符号链接")
|
|
||||||
target.parent.chmod(0o700)
|
|
||||||
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
|
|
||||||
member = Membership.objects.get(user=job.user, organization=job.dataset.organization)
|
member = Membership.objects.get(user=job.user, organization=job.dataset.organization)
|
||||||
if set(member.departments) != set(job.departments):
|
if set(member.departments) != set(job.departments):
|
||||||
raise PermissionError("科室授权已变更,请重新提交导出")
|
raise PermissionError("科室授权已变更,请重新提交导出")
|
||||||
|
root = _validated_export_directory(Path(settings.MEDIA_ROOT) / "formula-exports", "公式导出目录")
|
||||||
|
user_dir = _validated_export_directory(root / str(job.user_id), "公式导出用户目录")
|
||||||
|
target = _validated_export_target(user_dir, job)
|
||||||
|
# 路径完整验证通过后才把目标登记为可清理文件;此前任何失败都不会删除磁盘文件。
|
||||||
|
created.append(target)
|
||||||
|
temporary = target.with_name(f"{job.pk}.{secrets.token_hex(8)}.tmp")
|
||||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
created.append(temporary)
|
||||||
with os.fdopen(descriptor, "wb") as file:
|
with os.fdopen(descriptor, "wb") as file:
|
||||||
stats = build_formula_workbook(job.dataset, member, job.context, file)
|
stats = build_formula_workbook(job.dataset, member, job.context, file)
|
||||||
temporary.replace(target)
|
os.replace(temporary, target)
|
||||||
|
created = [target]
|
||||||
job.path, job.status, job.error = str(target), "succeeded", ""
|
job.path, job.status, job.error = str(target), "succeeded", ""
|
||||||
job.finished_at = timezone.now()
|
job.finished_at = timezone.now()
|
||||||
job.expires_at = timezone.now() + timedelta(hours=24)
|
job.expires_at = timezone.now() + timedelta(hours=24)
|
||||||
job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"])
|
job.save(update_fields=["path", "status", "error", "finished_at", "expires_at"])
|
||||||
return stats
|
return stats
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
for path in (temporary, target):
|
job.status, job.error, job.finished_at, job.expires_at = "failed", str(exc)[:500], timezone.now(), None
|
||||||
try:
|
# 只清理本次登记且确认位于受控目录内的文件;目录被换成符号链接时拒绝删除。
|
||||||
if path is not None and not path.is_symlink():
|
leftover = [path for path in created if _unlink_created_file(path) == "blocked"]
|
||||||
path.unlink(missing_ok=True)
|
job.path = str(target) if target in leftover else ""
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
job.status, job.error, job.path = "failed", str(exc)[:500], ""
|
|
||||||
job.finished_at, job.expires_at = timezone.now(), None
|
|
||||||
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
|
job.save(update_fields=["status", "error", "path", "finished_at", "expires_at"])
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,114 @@ def test_formula_export_worker_records_path_preparation_failure(workspace, setti
|
|||||||
assert job.status == "failed"
|
assert job.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _queued_job(ds):
|
||||||
|
return FormulaExport.objects.create(
|
||||||
|
dataset=ds,
|
||||||
|
user=ds.created_by,
|
||||||
|
context={"start": "2026-08", "end": "2026-08", "comparison": "yoy", "group": "department"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_export_rejects_symlinked_user_directory_without_touching_files(workspace, settings):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
protected = Path(settings.MEDIA_ROOT) / "protected"
|
||||||
|
protected.mkdir()
|
||||||
|
victim = protected / f"{job.pk}.xlsx"
|
||||||
|
victim.write_bytes(b"protected workbook")
|
||||||
|
(root / str(job.user_id)).symlink_to(protected, target_is_directory=True)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="符号链接"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "failed"
|
||||||
|
assert not job.path
|
||||||
|
assert victim.read_bytes() == b"protected workbook"
|
||||||
|
assert list(protected.glob("*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_export_rejects_symlinked_target_and_keeps_link(workspace, settings):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
user_dir = Path(settings.MEDIA_ROOT) / "formula-exports" / str(job.user_id)
|
||||||
|
user_dir.mkdir(parents=True)
|
||||||
|
protected_file = Path(settings.MEDIA_ROOT) / "protected.xlsx"
|
||||||
|
protected_file.write_bytes(b"protected workbook")
|
||||||
|
(user_dir / f"{job.pk}.xlsx").symlink_to(protected_file)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="普通文件"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "failed"
|
||||||
|
assert protected_file.read_bytes() == b"protected workbook"
|
||||||
|
assert (user_dir / f"{job.pk}.xlsx").is_symlink()
|
||||||
|
assert list(user_dir.glob("*.tmp")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_export_refuses_cleanup_when_parent_directory_swapped_to_symlink(
|
||||||
|
workspace, settings, monkeypatch
|
||||||
|
):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
root = Path(settings.MEDIA_ROOT) / "formula-exports"
|
||||||
|
user_dir = root / str(job.user_id)
|
||||||
|
user_dir.mkdir(parents=True)
|
||||||
|
protected = Path(settings.MEDIA_ROOT) / "protected"
|
||||||
|
protected.mkdir()
|
||||||
|
victim = protected / f"{job.pk}.xlsx"
|
||||||
|
victim.write_bytes(b"protected workbook")
|
||||||
|
|
||||||
|
def swap_and_fail(*args, **kwargs):
|
||||||
|
args[-1].write(b"partial")
|
||||||
|
user_dir.rename(user_dir.with_name("swapped"))
|
||||||
|
user_dir.symlink_to(protected, target_is_directory=True)
|
||||||
|
raise ValueError("controlled failure after path validation")
|
||||||
|
|
||||||
|
monkeypatch.setattr("finance.exporting.build_formula_workbook", swap_and_fail)
|
||||||
|
with pytest.raises(ValueError, match="controlled failure"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert job.status == "failed"
|
||||||
|
assert victim.read_bytes() == b"protected workbook"
|
||||||
|
assert user_dir.is_symlink()
|
||||||
|
# 本次创建的临时文件残留在被换名的原目录中,由清理命令按保留期回收。
|
||||||
|
assert list((root / "swapped").glob(f"{job.pk}.*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_formula_export_retry_after_failure_succeeds(workspace, monkeypatch):
|
||||||
|
from finance.exporting import process_formula_export
|
||||||
|
|
||||||
|
ds, *_ = workspace
|
||||||
|
job = _queued_job(ds)
|
||||||
|
|
||||||
|
def fail(*args, **kwargs):
|
||||||
|
args[-1].write(b"partial")
|
||||||
|
raise ValueError("controlled export failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr("finance.exporting.build_formula_workbook", fail)
|
||||||
|
with pytest.raises(ValueError, match="controlled export failure"):
|
||||||
|
process_formula_export(job.pk)
|
||||||
|
monkeypatch.undo()
|
||||||
|
|
||||||
|
stats = process_formula_export(job.pk)
|
||||||
|
job.refresh_from_db()
|
||||||
|
assert stats["rows"] == 5
|
||||||
|
assert job.status == "succeeded"
|
||||||
|
assert Path(job.path).read_bytes()[:2] == b"PK"
|
||||||
|
assert not list(Path(job.path).parent.glob(f"{job.pk}.*.tmp"))
|
||||||
|
|
||||||
|
|
||||||
def test_formula_workbook_rejects_unclassified_label_collision(workspace):
|
def test_formula_workbook_rejects_unclassified_label_collision(workspace):
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user