Files
insar-management-system-v2/docs/archive/SECURITY_AUDIT_ANALYSIS_2026-03-04.md
T

16 KiB
Raw Blame History

安全审计分析与修复方案(2026-03-04)

审计概述

本文档针对 SECURITY_AUDIT_2026-03-04.md 中提出的安全问题进行详细分析,并提供具体的修复方案。

审计发现:

  • 🔴 高危问题:3 个
  • 🟡 中危问题:4 个
  • 🟢 低危问题:1 个

1. 高危问题分析与修复方案

1.1 ENVI 主流程成功后不返回结果

问题描述

  • envi_service.py:run_workflow() 在成功执行后不返回 record 字典
  • envi_runner_cli.py 期望打印 JSON 结果,但 run_workflow() 在成功时返回 None
  • job_handlers.py:_run_envi_workflow_job() 尝试将返回值作为字典使用,导致崩溃

根本原因

# envi_service.py:1630
if error:
    raise RuntimeError(error)
# 缺少: return record

影响等级🔴 高危

  • 所有 ENVI 工作流任务在成功执行后会被误判为失败
  • 下游代码尝试访问 run_meta.get('workflow') 时会抛出 AttributeError

修复方案

# envi_service.py:1630 之后添加
if error:
    raise RuntimeError(error)
return record  # 添加此行

验证方法

  1. 运行一个 D-InSAR 工作流任务
  2. 检查任务状态是否正确标记为 SUCCESS
  3. 检查日志中是否包含 workflow=dinsar_custom duration=XXXs

1.2 只读用户可通过 GET 触发写操作

问题描述: 多个 GET 端点在只读操作中触发了数据库写入和高开销计算:

  1. GET /radar-data/{data_id}/thumb (radar.py:622)

    • 调用 _get_cached_radar_preview()_build_radar_preview_cache()
    • 触发预览图生成(GDAL 处理)并写入数据库
  2. GET /radar-data/imaging-dates (radar.py:609)

    • 虽然是只读查询,但在 _build_radar_preview_cache() 中会更新数据库
  3. GET /dinsar-results/{result_id}/preview (dinsar.py:214)

    • 类似问题,按需构建预览缓存

根本原因

# radar.py:365
if settings.RADAR_PREVIEW_BUILD_ON_DEMAND:
    build_status = await _build_radar_preview_cache(record, db, force=False)
    # 内部会执行 db.add(record) 和 db.commit()

影响等级🔴 高危

  • 只读账号可以触发大量 GDAL 处理任务,导致 CPU/内存耗尽(DoS)
  • 违反最小权限原则,读账号不应有写权限
  • 可能导致数据库锁竞争

修复方案

方案 A:权限分离(推荐)

# 在 dependencies.py 中添加权限检查
def require_write_permission(current_user: User = Depends(get_current_user)):
    if current_user.role == "readonly":
        raise HTTPException(
            status_code=403,
            detail="此操作需要写权限"
        )
    return current_user

# 在 radar.py 中修改端点
@router.get("/radar-data/{data_id}/thumb")
async def get_radar_data_thumb_endpoint(
    data_id: int,
    db: AsyncSession = Depends(get_db),
    _: User = Depends(require_write_permission)  # 添加权限检查
):
    # 仅当用户有写权限时才允许按需构建
    ...

方案 B:配置控制

# 在 .env 中添加
RADAR_PREVIEW_BUILD_ON_DEMAND_READONLY=false

# 在 radar.py 中修改
if settings.RADAR_PREVIEW_BUILD_ON_DEMAND:
    if current_user.role == "readonly" and not settings.RADAR_PREVIEW_BUILD_ON_DEMAND_READONLY:
        # 只返回已有缓存,不触发构建
        raise HTTPException(status_code=404, detail="预览图未生成")
    build_status = await _build_radar_preview_cache(record, db, force=False)

方案 C:改为 POST 端点(最符合 RESTful 原则)

# 将按需构建改为显式 POST 操作
@router.post("/radar-data/{data_id}/build-preview")
async def build_radar_preview_endpoint(
    data_id: int,
    db: AsyncSession = Depends(get_db),
    current_user: User = Depends(require_write_permission)
):
    # 显式构建预览图
    ...

# GET 端点只返回已有缓存
@router.get("/radar-data/{data_id}/thumb")
async def get_radar_data_thumb_endpoint(data_id: int, db: AsyncSession = Depends(get_db)):
    # 不触发构建,只返回已有文件
    if not os.path.exists(geo_cache_path):
        raise HTTPException(status_code=404, detail="预览图未生成,请先调用构建接口")
    return FileResponse(geo_cache_path, ...)

推荐方案:方案 C(最符合 RESTful 原则)


1.3 AOI token 内存存储无容量上限

问题描述

  • _AOI_TOKEN_STORE 是全局字典,无大小限制
  • 每次查询 AOI 时会续期 token(expires_at = now + AOI_TOKEN_TTL_SECONDS
  • 恶意用户可以通过频繁查询不同 AOI 区域来填满内存

根本原因

# dependencies.py:131
_AOI_TOKEN_STORE: Dict[str, Dict[str, Any]] = {}  # 无容量限制

# dependencies.py:850
payload["expires_at"] = now + AOI_TOKEN_TTL_SECONDS  # 每次访问都续期

影响等级🔴 高危

  • 认证后的低权限账号可以制造内存泄漏
  • 30 分钟 TTL + 续期机制 = 长期占用内存
  • 可能导致进程 OOM 崩溃

修复方案

方案 A:添加容量上限(推荐)

# dependencies.py
AOI_TOKEN_MAX_STORE_SIZE = read_int_env(
    "AOI_TOKEN_MAX_STORE_SIZE",
    1000,  # 最多存储 1000 个 token
    minimum=100,
    maximum=10000,
)

async def _store_aoi_token(aoi_wkt: str, feature_collection: Dict[str, Any]) -> str:
    token = uuid.uuid4().hex
    now = time.monotonic()
    async with _AOI_TOKEN_LOCK:
        _cleanup_expired_aoi_tokens(now)

        # 检查容量上限
        if len(_AOI_TOKEN_STORE) >= AOI_TOKEN_MAX_STORE_SIZE:
            # 删除最旧的 token(按 expires_at 排序)
            oldest_tokens = sorted(
                _AOI_TOKEN_STORE.items(),
                key=lambda x: x[1]["expires_at"]
            )[:100]  # 删除最旧的 100 个
            for old_token, _ in oldest_tokens:
                _AOI_TOKEN_STORE.pop(old_token, None)

        _AOI_TOKEN_STORE[token] = {
            "aoi_wkt": aoi_wkt,
            "aoi_geojson": feature_collection,
            "expires_at": now + AOI_TOKEN_TTL_SECONDS,
        }
    return token

方案 B:限制续期次数

# 在 token payload 中添加续期计数
_AOI_TOKEN_STORE[token] = {
    "aoi_wkt": aoi_wkt,
    "aoi_geojson": feature_collection,
    "expires_at": now + AOI_TOKEN_TTL_SECONDS,
    "renew_count": 0,  # 添加续期计数
    "max_renews": 5,   # 最多续期 5 次
}

# 在 _get_aoi_from_token 中检查
async def _get_aoi_from_token(aoi_token: Optional[str]) -> Optional[Tuple[str, Dict[str, Any]]]:
    ...
    payload = _AOI_TOKEN_STORE.get(normalized_token)
    if not payload:
        return None

    # 检查续期次数
    if payload.get("renew_count", 0) >= payload.get("max_renews", 5):
        # 达到续期上限,不再续期
        return payload.get("aoi_wkt"), payload.get("aoi_geojson")

    payload["expires_at"] = now + AOI_TOKEN_TTL_SECONDS
    payload["renew_count"] = payload.get("renew_count", 0) + 1
    return payload.get("aoi_wkt"), payload.get("aoi_geojson")

方案 C:使用 Redis(生产环境推荐)

# 使用 Redis 替代内存字典,自动过期
import redis.asyncio as redis

_redis_client = redis.Redis(
    host=settings.REDIS_HOST,
    port=settings.REDIS_PORT,
    decode_responses=False,
)

async def _store_aoi_token(aoi_wkt: str, feature_collection: Dict[str, Any]) -> str:
    token = uuid.uuid4().hex
    payload = {
        "aoi_wkt": aoi_wkt,
        "aoi_geojson": feature_collection,
    }
    await _redis_client.setex(
        f"aoi_token:{token}",
        AOI_TOKEN_TTL_SECONDS,
        json.dumps(payload)
    )
    return token

推荐方案:方案 A(短期)+ 方案 C(长期)


2. 中危问题分析与修复方案

2.1 extract_disp_results 失败计数重复累加

问题描述

# envi_service.py:1717, 1726, 1729
except OSError as e:
    task_failed += 1
    failed += 1  # 第一次累加
    task_status = f"error: {e}"
...
failed += task_failed  # 第二次累加(1729 行)

影响等级🟡 中危

  • 统计数据不准确,影响监控和告警
  • 可能导致误报(失败数翻倍)

修复方案

# envi_service.py:1717 和 1726 行,删除 failed += 1
except OSError as e:
    task_failed += 1
    # 删除: failed += 1
    task_status = f"error: {e}"

# 保留 1729 行的汇总
failed += task_failed  # 只在这里累加一次

2.2 AOI 文件解析异常未统一转为 4xx

问题描述

# dependencies.py:792, 800
# 解析 GeoJSON/Shapefile 时的异常直接抛出,返回 500
aoi_wkt = _parse_geojson_to_wkt(geojson_data)  # 可能抛出 ValueError

影响等级🟡 中危

  • 用户输入错误导致 500 错误,应该返回 400
  • 错误语义不一致,影响 API 可用性

修复方案

# dependencies.py 中添加统一异常处理
try:
    aoi_wkt = _parse_geojson_to_wkt(geojson_data)
except (ValueError, KeyError, TypeError) as e:
    raise HTTPException(
        status_code=400,
        detail=f"GeoJSON 格式错误: {str(e)}"
    )
except Exception as e:
    logger.error(f"AOI 解析失败: {e}")
    raise HTTPException(
        status_code=500,
        detail="AOI 解析失败,请联系管理员"
    )

2.3 路径归属判断使用 startswith

问题描述

# data_service.py:530
if not any(file_path_abs.startswith(root) for root in radar_roots):
    continue

影响等级🟡 中危

  • 前缀误匹配:/data/radar 会匹配 /data/radar_backup
  • 可能将不属于监控目录的文件误判为合法

修复方案

# 使用 os.path.commonpath 或规范化路径比较
def is_subpath(child: str, parent: str) -> bool:
    """检查 child 是否是 parent 的子路径"""
    try:
        child_abs = os.path.abspath(child)
        parent_abs = os.path.abspath(parent)
        common = os.path.commonpath([child_abs, parent_abs])
        return common == parent_abs
    except (ValueError, TypeError):
        return False

# data_service.py:530
if not any(is_subpath(file_path_abs, root) for root in radar_roots):
    continue

2.4 解包安全校验仅检查 member.name

问题描述

# unpack_archives.py:98-104
def _is_safe_tar_member(member_name):
    norm_name = os.path.normpath(member_name)
    if os.path.isabs(norm_name):
        return False
    if norm_name.startswith(".."):
        return False
    return True

# 但仍使用 extractall,未检查符号链接/硬链接
tar.extractall(path=tmp_dir)  # 181 行

影响等级🟡 中危

  • 符号链接可以指向任意路径(如 /etc/passwd
  • 硬链接可以覆盖系统文件
  • 如果归档文件来自不可信来源,存在目录逃逸风险

修复方案

# unpack_archives.py
def _is_safe_tar_member(member):
    """检查 tar 成员是否安全(包括符号链接检查)"""
    # 检查路径
    norm_name = os.path.normpath(member.name)
    if os.path.isabs(norm_name):
        return False
    if norm_name.startswith("..") or norm_name.startswith("../") or norm_name.startswith("..\\"):
        return False

    # 检查符号链接
    if member.issym() or member.islnk():
        # 检查链接目标是否安全
        link_target = member.linkname
        if os.path.isabs(link_target) or link_target.startswith(".."):
            return False

    return True

def _validate_tar_members(tar_obj, archive_path):
    for member in tar_obj.getmembers():
        if not _is_safe_tar_member(member):  # 传入 member 对象而非 name
            raise IOError(f"unsafe tar entry detected: {member.name} in {archive_path}")

# 使用安全的逐个提取方式
def _safe_extract_tar(tar_obj, output_dir):
    """安全地提取 tar 文件"""
    for member in tar_obj.getmembers():
        if not _is_safe_tar_member(member):
            raise IOError(f"unsafe tar entry: {member.name}")

        # 逐个提取,避免 extractall
        tar_obj.extract(member, path=output_dir, filter='data')  # Python 3.12+
        # 或者对于旧版本:
        # tar_obj.extract(member, path=output_dir)

# 在 181 行替换
with tarfile.open(archive_path, "r:*") as tar:
    _validate_tar_members(tar, archive_path)
    _safe_extract_tar(tar, tmp_dir)  # 替换 tar.extractall

3. 低危问题分析与修复方案

3.1 启动脚本写 nginx.conf 未显式编码

问题描述

# start_app.ps1:388
$NewConfContent | Set-Content -LiteralPath "$NginxConfPath" -NoNewline
# 未指定 -Encoding UTF8

影响等级🟢 低危

  • PowerShell 5 默认使用 UTF-16 LE 编码
  • Nginx 期望 UTF-8 编码,可能导致配置解析失败

修复方案

# start_app.ps1:388
$NewConfContent | Set-Content -LiteralPath "$NginxConfPath" -NoNewline -Encoding UTF8

4. 修复优先级建议

P0(立即修复)

  1. 1.1 ENVI 主流程返回值缺失 - 影响所有工作流任务
  2. 1.2 只读用户触发写操作 - 权限绕过 + DoS 风险

P1(本周内修复)

  1. 1.3 AOI token 内存泄漏 - DoS 风险
  2. 2.1 失败计数重复累加 - 影响监控准确性

P2(两周内修复)

  1. 2.2 AOI 解析异常处理 - API 可用性问题
  2. 2.3 路径归属判断 - 潜在安全风险

P3(一个月内修复)

  1. 2.4 解包安全校验 - 取决于归档文件来源可信度
  2. 3.1 PowerShell 编码 - 低影响,但易修复

5. 修复验证清单

1.1 ENVI 主流程返回值

  • 运行 D-InSAR 工作流任务
  • 检查任务状态是否为 SUCCESS
  • 检查日志是否包含 workflow=dinsar_custom

1.2 只读用户权限

  • 使用只读账号访问 /radar-data/{id}/thumb
  • 验证是否返回 403 或 404(不触发构建)
  • 使用写权限账号验证按需构建仍可用

1.3 AOI token 容量

  • 创建 1000+ 个不同 AOI token
  • 验证内存占用是否稳定
  • 检查日志是否有 token 清理记录

2.1 失败计数

  • 运行 extract_disp_results 并故意触发失败
  • 验证返回的 failed 计数是否准确

2.2 AOI 解析异常

  • 上传无效 GeoJSON 文件
  • 验证是否返回 400 而非 500

2.3 路径归属判断

  • 创建 /data/radar_backup/test.tif 文件
  • 验证是否被误判为 /data/radar 的子路径

2.4 解包安全

  • 创建包含符号链接的 tar 文件
  • 验证是否被拒绝解包

3.1 PowerShell 编码

  • 在 PowerShell 5 环境运行启动脚本
  • 验证 nginx.conf 是否为 UTF-8 编码

6. 长期改进建议

6.1 引入速率限制

# 使用 slowapi 限制 API 调用频率
from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)

@router.get("/radar-data/{data_id}/thumb")
@limiter.limit("10/minute")  # 每分钟最多 10 次
async def get_radar_data_thumb_endpoint(...):
    ...

6.2 添加审计日志

# 记录所有写操作和高开销操作
async def audit_log(
    user_id: int,
    action: str,
    resource: str,
    details: Dict[str, Any]
):
    await db.execute(
        insert(AuditLogORM).values(
            user_id=user_id,
            action=action,
            resource=resource,
            details=details,
            timestamp=datetime.utcnow(),
        )
    )

6.3 使用 Redis 替代内存缓存

  • AOI token 存储
  • 预览图构建队列
  • 速率限制计数器

6.4 添加资源配额

# 限制每个用户的资源使用
USER_QUOTA = {
    "readonly": {
        "max_concurrent_requests": 5,
        "max_aoi_tokens": 10,
        "max_preview_builds_per_hour": 0,
    },
    "user": {
        "max_concurrent_requests": 20,
        "max_aoi_tokens": 50,
        "max_preview_builds_per_hour": 100,
    },
    "admin": {
        "max_concurrent_requests": 100,
        "max_aoi_tokens": 500,
        "max_preview_builds_per_hour": 1000,
    },
}

7. 总结

本次审计发现了 3 个高危问题4 个中危问题1 个低危问题,主要集中在:

  1. 函数返回值缺失导致任务失败
  2. 权限控制不严格(读账号可触发写操作)
  3. 资源无限制(内存泄漏风险)
  4. 统计数据不准确
  5. 异常处理不规范

建议按照优先级逐步修复,并在修复后进行充分的回归测试。长期来看,应该引入更完善的权限管理、资源配额、审计日志和速率限制机制。