diff --git a/ruoyi-fastapi-backend/cli/context.py b/ruoyi-fastapi-backend/cli/context.py index adbfab968..9fa861d42 100644 --- a/ruoyi-fastapi-backend/cli/context.py +++ b/ruoyi-fastapi-backend/cli/context.py @@ -11,7 +11,8 @@ class CliContext: """ CLI 运行上下文。 - env: 当前命令使用的环境名称 + env: 当前加载的配置文件选择名 + app_env: 已加载配置声明的实际应用环境 output: 结果输出格式 operator: 操作者标识,后续用于审计 allow_prod: 是否显式允许生产环境执行危险命令 @@ -25,6 +26,7 @@ class CliContext: allow_prod: bool = False yes: bool = False dry_run: bool = False + app_env: str | None = None class CliContextBuilder: @@ -53,8 +55,12 @@ def build( :param dry_run: 是否执行演练模式 :return: 构建完成的 CLI 上下文 """ + # 必须与实际资源配置使用同一来源,命令参数默认值不能覆盖已加载的环境。 + from config.env import AppConfig, get_config # noqa: PLC0415 - 保留 CLI 启动时的惰性配置加载 + return CliContext( - env=env, + env=get_config.run_env, + app_env=AppConfig.app_env, output=output, allow_prod=allow_prod, yes=yes, @@ -66,7 +72,12 @@ def build( EnvOption = Annotated[ str, - typer.Option('--env', help='运行环境名称', autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values), + typer.Option( + '--env', + envvar='APP_ENV', + help='运行环境配置名称', + autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values, + ), ] OutputOption = Annotated[Literal['text', 'json'], typer.Option('--output', help='输出格式,支持 text 或 json')] AllowProdOption = Annotated[bool, typer.Option('--allow-prod', help='允许在生产环境中执行危险命令')] diff --git a/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py index 47aba0f43..a8d1895f9 100644 --- a/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py +++ b/ruoyi-fastapi-backend/cli/groups/plugin/artifact_controller.py @@ -165,9 +165,8 @@ def _context( yes: bool = False, dry_run: bool = False, ) -> CliContext: - # config.env 可能在应用日志策略时首次导入,因此必须先设置命令选择的环境。 """ - 先设置命令环境,再应用危险操作保护并解析操作者。 + 使用统一执行上下文应用危险操作保护并解析操作者。 :param env: 当前命令运行环境 :param output: 输出格式 @@ -177,7 +176,6 @@ def _context( :param dry_run: 是否仅预演而不写入发布状态 :return: 完成环境与操作者设置的 CLI 上下文 """ - os.environ['APP_ENV'] = env if command_name and not dry_run: ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, dry_run, command_name=command_name) else: diff --git a/ruoyi-fastapi-backend/cli/guards.py b/ruoyi-fastapi-backend/cli/guards.py index 2bad51f94..75cc1cbce 100644 --- a/ruoyi-fastapi-backend/cli/guards.py +++ b/ruoyi-fastapi-backend/cli/guards.py @@ -122,7 +122,8 @@ def confirm(self, ctx: CliContext, *, command_name: str) -> CommandResult | None try: confirmed = typer.confirm( - f'确认执行危险命令 `{command_name}` 吗? 当前环境:{ctx.env}{"(dry-run)" if ctx.dry_run else ""}', + f'确认执行危险命令 `{command_name}` 吗? 配置:{ctx.env},' + f'应用环境:{ctx.app_env or ctx.env}{"(dry-run)" if ctx.dry_run else ""}', default=False, ) except (click.Abort, EOFError, KeyboardInterrupt): @@ -173,7 +174,7 @@ def guard(self, ctx: CliContext, *, rule: DangerousCommandRule) -> CommandResult :return: 拒绝结果或 None """ command_name = rule.command_name - if ctx.env == 'prod' and not ctx.allow_prod: + if 'prod' in {ctx.env.strip().lower(), (ctx.app_env or '').strip().lower()} and not ctx.allow_prod: return self.result_builder.build_guard_reject_result( f'生产环境默认禁止直接执行危险命令:{command_name}', '如确认执行,请传入 --allow-prod;如需跳过确认,请同时传入 --yes', diff --git a/ruoyi-fastapi-backend/cli/runtime/job/service.py b/ruoyi-fastapi-backend/cli/runtime/job/service.py index 6f6c38e4b..705e46cd8 100644 --- a/ruoyi-fastapi-backend/cli/runtime/job/service.py +++ b/ruoyi-fastapi-backend/cli/runtime/job/service.py @@ -211,8 +211,10 @@ async def run_with_scheduler_context( 'operation': operation, 'operationLabel': operation_metadata['operationLabel'], 'targetStatus': status, - 'message': operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败', + 'message': result.message + or (operation_metadata['successMessage'] if result.is_success else '定时任务操作执行失败'), 'serviceMessage': result.message, + 'result': result.result, } async def run_job_once(self, job_id: int) -> dict[str, Any]: diff --git a/ruoyi-fastapi-backend/common/annotation/log_annotation.py b/ruoyi-fastapi-backend/common/annotation/log_annotation.py index 6353d0c57..a5db0caee 100644 --- a/ruoyi-fastapi-backend/common/annotation/log_annotation.py +++ b/ruoyi-fastapi-backend/common/annotation/log_annotation.py @@ -11,7 +11,10 @@ import httpx from async_lru import alru_cache from fastapi import Request +from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, ORJSONResponse, UJSONResponse +from pydantic_validation_decorator import FieldValidationError +from starlette.exceptions import HTTPException from starlette.status import HTTP_200_OK from typing_extensions import ParamSpec from user_agents import parse @@ -20,8 +23,13 @@ from common.enums import BusinessType from config.env import AppConfig from exceptions.exception import ( + AuthException, FileRangeNotSatisfiableException, LoginException, + ModelValidatorException, + OAuthProtocolException, + OidcInteractionException, + PermissionException, ServiceException, ServiceWarning, ) @@ -29,6 +37,7 @@ from module_admin.service.log_service import LogQueueService from utils.client_ip_util import ClientIPUtil from utils.dependency_util import DependencyUtil +from utils.exception_util import ExceptionUtil from utils.log_util import LogSanitizer, logger from utils.response_util import ResponseUtil from utils.time_util import TimezoneUtil @@ -193,79 +202,94 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: oper_time = TimezoneUtil.utc_now() # 此处在登录之前向原始函数传递一些登录信息,用于监测在线用户的相关信息 login_log = self._get_login_log(user_agent, oper_ip, oper_location, oper_time, kwargs) + raised_exception = None try: # 调用原始函数 result = await func(*args, **kwargs) - except (LoginException, ServiceWarning) as e: - logger.warning(e.message) - result = ResponseUtil.failure(data=e.data, msg=e.message) - except ServiceException as e: - logger.error(e.message) - result = ResponseUtil.error(data=e.data, msg=e.message) - except FileRangeNotSatisfiableException: - raise + except ( + AuthException, + PermissionException, + LoginException, + ServiceException, + ServiceWarning, + ModelValidatorException, + FieldValidationError, + FileRangeNotSatisfiableException, + HTTPException, + RequestValidationError, + OAuthProtocolException, + OidcInteractionException, + ) as e: + # 已知异常仅构造审计摘要,实际响应仍交由全局异常处理器生成。 + raised_exception = e + result = ResponseUtil.error(msg=getattr(e, 'message', None) or str(e)) except Exception as e: - logger.exception(e) - result = ResponseUtil.error(msg=str(e)) - # 获取请求耗时 - cost_time = float(time.perf_counter() - start_time) * 1000 - # 判断请求是否来自api文档 - request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request) - # 根据响应结果的类型使用不同的方法获取响应结果参数 - sanitized_result_dict = LogSanitizer.sanitize_data( - self._get_result_dict(result, request_from_swagger, request_from_redoc) - ) - json_result = self._build_log_text( - sanitized_result_dict, - self.response_log_mode, - self.response_include_fields, - self.response_exclude_fields, - payload_kind='response', - ) - # 日志表返回参数字段长度最大为2000,因此在此处判断长度 - json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长') - # 根据响应结果获取响应状态及异常信息 - status, error_msg = self._get_status_and_error_msg(sanitized_result_dict) - # 根据日志类型向对应的日志表插入数据 - if self.log_type == 'login': - # 登录请求来自于api文档时不记录登录日志,其余情况则记录 - if request_from_swagger or request_from_redoc: - pass + result = ExceptionUtil.handle_unexpected_exception(e) + try: + # 获取请求耗时 + cost_time = float(time.perf_counter() - start_time) * 1000 + # 判断请求是否来自api文档 + request_from_swagger, request_from_redoc = self._is_request_from_swagger_or_redoc(request) + # 根据响应结果的类型使用不同的方法获取响应结果参数 + sanitized_result_dict = LogSanitizer.sanitize_data( + self._get_result_dict(result, request_from_swagger, request_from_redoc) + ) + json_result = self._build_log_text( + sanitized_result_dict, + self.response_log_mode, + self.response_include_fields, + self.response_exclude_fields, + payload_kind='response', + ) + # 日志表返回参数字段长度最大为2000,因此在此处判断长度 + json_result = self._limit_log_text(json_result, self._json_result_len, '返回参数过长') + # 根据响应结果获取响应状态及异常信息 + status, error_msg = self._get_status_and_error_msg(sanitized_result_dict) + # 根据日志类型向对应的日志表插入数据 + if self.log_type == 'login': + # 登录请求来自于api文档时不记录登录日志,其余情况则记录 + if request_from_swagger or request_from_redoc: + pass + else: + user = kwargs.get('form_data') + login_log.update( + { + 'loginTime': oper_time, + 'userName': user.username, + 'status': str(status), + 'msg': sanitized_result_dict.get('msg') or '', + } + ) + + await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path) else: - user = kwargs.get('form_data') - login_log.update( - { - 'loginTime': oper_time, - 'userName': user.username, - 'status': str(status), - 'msg': sanitized_result_dict.get('msg') or '', - } + current_user = RequestContext.get_current_user() + oper_name = current_user.user.user_name + dept_name = current_user.user.dept.dept_name if current_user.user.dept else None + operation_log = OperLogModel( + title=self.title, + businessType=self.business_type, + method=func_path, + requestMethod=request_method, + operatorType=operator_type, + operName=oper_name, + deptName=dept_name, + operUrl=oper_url, + operIp=oper_ip, + operLocation=oper_location, + operParam=oper_param, + jsonResult=json_result, + status=status, + errorMsg=error_msg, + operTime=oper_time, + costTime=int(cost_time), ) + await LogQueueService.enqueue_operation_log(request, operation_log, func_path) - await LogQueueService.enqueue_login_log(request, LogininforModel(**login_log), func_path) - else: - current_user = RequestContext.get_current_user() - oper_name = current_user.user.user_name - dept_name = current_user.user.dept.dept_name if current_user.user.dept else None - operation_log = OperLogModel( - title=self.title, - businessType=self.business_type, - method=func_path, - requestMethod=request_method, - operatorType=operator_type, - operName=oper_name, - deptName=dept_name, - operUrl=oper_url, - operIp=oper_ip, - operLocation=oper_location, - operParam=oper_param, - jsonResult=json_result, - status=status, - errorMsg=error_msg, - operTime=oper_time, - costTime=int(cost_time), - ) - await LogQueueService.enqueue_operation_log(request, operation_log, func_path) + finally: + # 即使审计写入失败,也不能覆盖原有认证或协议异常。 + if raised_exception is not None: + raise raised_exception return result diff --git a/ruoyi-fastapi-backend/common/aspect/data_scope.py b/ruoyi-fastapi-backend/common/aspect/data_scope.py index 80d83048b..0d3384101 100644 --- a/ruoyi-fastapi-backend/common/aspect/data_scope.py +++ b/ruoyi-fastapi-backend/common/aspect/data_scope.py @@ -1,5 +1,5 @@ from fastapi import Depends, Request, params -from sqlalchemy import ColumnElement, func, or_, select +from sqlalchemy import ColumnElement, false, func, or_, select, true from common.context import RequestContext from config.database import Base @@ -39,14 +39,15 @@ def __init__( def __call__(self, request: Request) -> ColumnElement: DependencyUtil.check_exclude_routes(request, err_msg='当前路由不在认证规则内,不可使用GetDataScope依赖项') current_user = RequestContext.get_current_user() + if current_user.user.admin: + return true() user_id = current_user.user.user_id dept_id = current_user.user.dept_id - custom_data_scope_role_id_list = [ - item.role_id for item in current_user.user.role if item.data_scope == self.DATA_SCOPE_CUSTOM - ] + roles = [role for role in (current_user.user.role or []) if role.status == '0' and role.del_flag == '0'] + custom_data_scope_role_id_list = [item.role_id for item in roles if item.data_scope == self.DATA_SCOPE_CUSTOM] param_sql_list = [] - for role in current_user.user.role: - if current_user.user.admin or role.data_scope == self.DATA_SCOPE_ALL: + for role in roles: + if role.data_scope == self.DATA_SCOPE_ALL: param_sql_list = [True] break if role.data_scope == self.DATA_SCOPE_CUSTOM: @@ -91,7 +92,7 @@ def __call__(self, request: Request) -> ColumnElement: else: param_sql_list.append(False) param_sql_list = list(dict.fromkeys(param_sql_list)) - param_sql = or_(*param_sql_list) + param_sql = or_(false(), *param_sql_list) return param_sql diff --git a/ruoyi-fastapi-backend/exceptions/handle.py b/ruoyi-fastapi-backend/exceptions/handle.py index 996fb0d36..7bad197dc 100644 --- a/ruoyi-fastapi-backend/exceptions/handle.py +++ b/ruoyi-fastapi-backend/exceptions/handle.py @@ -1,10 +1,10 @@ from urllib.parse import urlsplit from fastapi import FastAPI, Request, Response, status -from fastapi.exceptions import HTTPException from fastapi.responses import JSONResponse as FastAPIJSONResponse from fastapi.responses import RedirectResponse from pydantic_validation_decorator import FieldValidationError +from starlette.exceptions import HTTPException from exceptions.exception import ( AuthException, @@ -17,6 +17,7 @@ ServiceException, ServiceWarning, ) +from utils.exception_util import ExceptionUtil from utils.log_util import logger from utils.oidc_util import OidcUtil from utils.response_util import JSONResponse, ResponseUtil, jsonable_encoder @@ -118,14 +119,15 @@ async def file_range_not_satisfiable_exception_handler( @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException) -> Response: return JSONResponse( - content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}), status_code=exc.status_code + content=jsonable_encoder({'code': exc.status_code, 'msg': exc.detail}), + status_code=exc.status_code, + headers=exc.headers, ) # 处理其他异常 @app.exception_handler(Exception) async def exception_handler(request: Request, exc: Exception) -> Response: - logger.exception(exc) - return ResponseUtil.error(msg=str(exc)) + return ExceptionUtil.handle_unexpected_exception(exc) def _build_oauth_redirect(exc: OAuthProtocolException) -> Response: diff --git a/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py b/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py index bfb55f0b4..ab5399331 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/dept_controller.py @@ -83,7 +83,10 @@ async def add_system_dept( add_dept: DeptModel, query_db: Annotated[AsyncSession, DBSessionDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()], + data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], ) -> Response: + if not current_user.user.admin: + await DeptService.check_dept_data_scope_services(query_db, add_dept.parent_id, data_scope_sql) add_dept.create_by = current_user.user.user_name add_dept.update_by = current_user.user.user_name add_dept_result = await DeptService.add_dept_services(query_db, add_dept) @@ -111,6 +114,9 @@ async def edit_system_dept( ) -> Response: if not current_user.user.admin: await DeptService.check_dept_data_scope_services(query_db, edit_dept.dept_id, data_scope_sql) + dept_info = await DeptService.dept_detail_services(query_db, edit_dept.dept_id) + if edit_dept.parent_id is not None and edit_dept.parent_id != dept_info.parent_id: + await DeptService.check_dept_data_scope_services(query_db, edit_dept.parent_id, data_scope_sql) edit_dept.update_by = current_user.user.user_name edit_dept_result = await DeptService.edit_dept_services(query_db, edit_dept) logger.info(edit_dept_result.message) diff --git a/ruoyi-fastapi-backend/module_admin/controller/user_controller.py b/ruoyi-fastapi-backend/module_admin/controller/user_controller.py index a720a5aba..434ae9ba8 100644 --- a/ruoyi-fastapi-backend/module_admin/controller/user_controller.py +++ b/ruoyi-fastapi-backend/module_admin/controller/user_controller.py @@ -152,6 +152,26 @@ async def edit_system_user( dept_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], role_data_scope_sql: Annotated[ColumnElement, DataScopeDependency(SysDept)], ) -> Response: + # 普通编辑只接收管理表单字段,密码与内部操作类型由专用接口处理。 + edit_user = EditUserModel( + **edit_user.model_dump( + by_alias=True, + exclude_unset=True, + include={ + 'user_id', + 'dept_id', + 'user_name', + 'nick_name', + 'email', + 'phonenumber', + 'sex', + 'status', + 'remark', + }, + ), + roleIds=edit_user.role_ids or [], + postIds=edit_user.post_ids or [], + ) await UserService.check_user_allowed_services(edit_user) if not current_user.user.admin: await UserService.check_user_data_scope_services(query_db, edit_user.user_id, user_data_scope_sql) diff --git a/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py b/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py index 055ffb54f..030a67976 100644 --- a/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py +++ b/ruoyi-fastapi-backend/module_admin/entity/vo/notice_vo.py @@ -1,11 +1,12 @@ from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic.alias_generators import to_camel from pydantic_validation_decorator import NotBlank, Size, Xss from common.mixin import DateRangeQueryMixin from common.types import ApiUtcDateTime +from utils.security_util import SecurityUtil class NoticeModel(BaseModel): @@ -26,6 +27,18 @@ class NoticeModel(BaseModel): update_time: ApiUtcDateTime | None = Field(default=None, description='更新时间') remark: str | None = Field(default=None, description='备注') + @field_validator('notice_content') + @classmethod + def sanitize_content(cls, value: bytes | None) -> bytes | None: + """ + 清洗公告正文,同时保护 API 写入及历史详情读取。 + + :param value: 公告正文二进制内容 + :return: 清洗后的正文 + """ + cleaned = SecurityUtil.sanitize_html(value) + return cleaned.encode('utf-8') if cleaned is not None else None + @Xss(field_name='notice_title', message='公告标题不能包含脚本字符') @NotBlank(field_name='notice_title', message='公告标题不能为空') @Size(field_name='notice_title', min_length=0, max_length=50, message='公告标题不能超过50个字符') diff --git a/ruoyi-fastapi-backend/module_admin/service/dept_service.py b/ruoyi-fastapi-backend/module_admin/service/dept_service.py index 17a20387e..41d8ac68c 100644 --- a/ruoyi-fastapi-backend/module_admin/service/dept_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/dept_service.py @@ -113,7 +113,9 @@ async def add_dept_services(cls, query_db: AsyncSession, page_object: DeptModel) """ if not await cls.check_dept_name_unique_services(query_db, page_object): raise ServiceException(message=f'新增部门{page_object.dept_name}失败,部门名称已存在') - parent_info = await DeptDao.get_dept_by_id(query_db, page_object.parent_id) + parent_info = await DeptDao.get_dept_detail_by_id(query_db, page_object.parent_id) + if not parent_info: + raise ServiceException(message='上级部门不存在') if parent_info.status != CommonConstant.DEPT_NORMAL: raise ServiceException(message=f'部门{parent_info.dept_name}停用,不允许新增') page_object.ancestors = f'{parent_info.ancestors},{page_object.parent_id}' @@ -134,6 +136,11 @@ async def edit_dept_services(cls, query_db: AsyncSession, page_object: DeptModel :param page_object: 编辑部门对象 :return: 编辑部门校验结果 """ + old_dept = await DeptDao.get_dept_detail_by_id(query_db, page_object.dept_id) + if not old_dept: + raise ServiceException(message='部门不存在') + if page_object.parent_id is None: + page_object.parent_id = old_dept.parent_id if not await cls.check_dept_name_unique_services(query_db, page_object): raise ServiceException(message=f'修改部门{page_object.dept_name}失败,部门名称已存在') if page_object.dept_id == page_object.parent_id: @@ -143,20 +150,26 @@ async def edit_dept_services(cls, query_db: AsyncSession, page_object: DeptModel and (await DeptDao.count_normal_children_dept_dao(query_db, page_object.dept_id)) > 0 ): raise ServiceException(message=f'修改部门{page_object.dept_name}失败,该部门包含未停用的子部门') - new_parent_dept = await DeptDao.get_dept_by_id(query_db, page_object.parent_id) - old_dept = await DeptDao.get_dept_by_id(query_db, page_object.dept_id) + # 根部门使用虚拟父节点 0,其他父部门必须存在且不能是当前部门的后代。 + new_ancestors = '0' + if page_object.parent_id != 0: + new_parent_dept = await DeptDao.get_dept_detail_by_id(query_db, page_object.parent_id) + if not new_parent_dept: + raise ServiceException(message='上级部门不存在') + if str(page_object.dept_id) in (new_parent_dept.ancestors or '').split(','): + raise ServiceException(message='上级部门不能是当前部门的子部门') + new_ancestors = f'{new_parent_dept.ancestors},{new_parent_dept.dept_id}' + # 祖级列表只根据服务端部门关系生成,不接受客户端直接修改。 + page_object.ancestors = new_ancestors try: - if new_parent_dept and old_dept: - new_ancestors = f'{new_parent_dept.ancestors},{new_parent_dept.dept_id}' - old_ancestors = old_dept.ancestors - page_object.ancestors = new_ancestors - await cls.update_dept_children(query_db, page_object.dept_id, new_ancestors, old_ancestors) + if new_ancestors != old_dept.ancestors: + await cls.update_dept_children(query_db, page_object.dept_id, new_ancestors, old_dept.ancestors) edit_dept = page_object.model_dump(exclude_unset=True, exclude={'create_time', 'update_time'}) await DeptDao.edit_dept_dao(query_db, edit_dept) if ( page_object.status == CommonConstant.DEPT_NORMAL and page_object.ancestors - and page_object.ancestors != 0 + and page_object.ancestors != '0' ): await cls.update_parent_dept_status_normal(query_db, page_object) await query_db.commit() diff --git a/ruoyi-fastapi-backend/module_admin/service/menu_service.py b/ruoyi-fastapi-backend/module_admin/service/menu_service.py index 6909a894c..9971611e9 100644 --- a/ruoyi-fastapi-backend/module_admin/service/menu_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/menu_service.py @@ -163,20 +163,33 @@ async def edit_menu_services(cls, query_db: AsyncSession, page_object: MenuModel 编辑菜单信息service :param query_db: orm对象 - :param page_object: 编辑部门对象 + :param page_object: 编辑菜单对象 :return: 编辑菜单校验结果 """ - edit_menu = page_object.model_dump(exclude_unset=True, exclude={'create_time', 'update_time'}) menu_info = await cls.menu_detail_services(query_db, page_object.menu_id) if menu_info.menu_id: + if page_object.parent_id is None: + page_object.parent_id = menu_info.parent_id if not await cls.check_menu_name_unique_services(query_db, page_object): raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,菜单名称已存在') if page_object.is_frame == MenuConstant.YES_FRAME and not StringUtil.is_http(page_object.path): raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,地址必须以http(s)://开头') if page_object.menu_id == page_object.parent_id: raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,上级菜单不能选择自己') + # 沿父级链校验,防止选择子菜单或接入已有的循环关系。 + parent_id = page_object.parent_id + visited_ids = {page_object.menu_id} + while parent_id != MenuConstant.ROOT_ID: + if parent_id in visited_ids: + raise ServiceException(message='上级菜单存在循环关系') + visited_ids.add(parent_id) + parent_menu = await MenuDao.get_menu_detail_by_id(query_db, parent_id) + if not parent_menu: + raise ServiceException(message='上级菜单不存在') + parent_id = parent_menu.parent_id if not await cls.check_route_config_unique_services(query_db, page_object): raise ServiceException(message=f'修改菜单{page_object.menu_name}失败,路由名称或地址已存在') + edit_menu = page_object.model_dump(exclude_unset=True, exclude={'create_time', 'update_time'}) try: await MenuDao.edit_menu_dao(query_db, edit_menu) await query_db.commit() diff --git a/ruoyi-fastapi-backend/module_admin/service/notice_service.py b/ruoyi-fastapi-backend/module_admin/service/notice_service.py index 0f09a92a4..4d619516c 100644 --- a/ruoyi-fastapi-backend/module_admin/service/notice_service.py +++ b/ruoyi-fastapi-backend/module_admin/service/notice_service.py @@ -15,6 +15,7 @@ NoticeTopResponseModel, ) from utils.common_util import CamelCaseUtil +from utils.security_util import SecurityUtil class NoticeService: @@ -38,6 +39,10 @@ async def get_notice_list_services( """ notice_list_result = await NoticeDao.get_notice_list(query_db, query_object, is_page) + rows = notice_list_result.rows if isinstance(notice_list_result, PageModel) else notice_list_result + for row in rows: + if 'noticeContent' in row: + row['noticeContent'] = SecurityUtil.sanitize_html(row['noticeContent']) return notice_list_result @classmethod diff --git a/ruoyi-fastapi-backend/requirements-pg.txt b/ruoyi-fastapi-backend/requirements-pg.txt index 384b38cbd..c512bc7e4 100644 --- a/ruoyi-fastapi-backend/requirements-pg.txt +++ b/ruoyi-fastapi-backend/requirements-pg.txt @@ -6,6 +6,7 @@ asyncpg==0.32.0 bcrypt==5.0.0 fastapi[all]==0.143.3 loguru==0.7.3 +nh3==0.3.7 openpyxl==3.1.5 packaging==26.3 pandas==2.3.3 diff --git a/ruoyi-fastapi-backend/requirements.txt b/ruoyi-fastapi-backend/requirements.txt index 4660e6e08..25c6600db 100644 --- a/ruoyi-fastapi-backend/requirements.txt +++ b/ruoyi-fastapi-backend/requirements.txt @@ -6,6 +6,7 @@ asyncmy==0.2.16 bcrypt==5.0.0 fastapi[all]==0.143.3 loguru==0.7.3 +nh3==0.3.7 openpyxl==3.1.5 packaging==26.3 pandas==2.3.3 diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py b/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py new file mode 100644 index 000000000..fa6f640bd --- /dev/null +++ b/ruoyi-fastapi-backend/tests/cli/root/test_effective_environment.py @@ -0,0 +1,96 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +import typer +from typer.testing import CliRunner + +from cli.context import EnvOption + +BACKEND_DIR = Path(__file__).resolve().parents[3] + + +@pytest.mark.parametrize( + ('external_env', 'selected', 'file_env', 'expected_selector', 'expected_app', 'rejected'), + [ + (None, None, 'dev', 'dev', 'dev', False), + ('prod', None, 'dev', 'prod', 'prod', True), + (None, 'prod', 'prod', 'prod', 'prod', True), + (None, 'dockermy', 'prod', 'dockermy', 'prod', True), + (None, 'dockerpg', 'prod', 'dockerpg', 'prod', True), + ('prod', 'dev', 'dev', 'dev', 'prod', True), + ('dev', 'prod', 'prod', 'prod', 'dev', True), + ], +) +def test_guard_uses_loaded_environment_without_resource_access( + tmp_path: Path, + external_env: str | None, + selected: str | None, + file_env: str, + expected_selector: str, + expected_app: str, + rejected: bool, +) -> None: + (tmp_path / f'.env.{expected_selector}').write_text(f'APP_ENV={file_env}\n', encoding='utf-8') + script = """ +import json +from cli.context import CLI_CONTEXT_BUILDER +from cli.guards import DEFAULT_DANGEROUS_COMMAND_GUARD, DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY +ctx = CLI_CONTEXT_BUILDER.build('dev', 'json', False, True, False) +rule = DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY.require_rule('job run-once') +result = DEFAULT_DANGEROUS_COMMAND_GUARD.guard(ctx, rule=rule) +ctx.allow_prod = True +allowed = DEFAULT_DANGEROUS_COMMAND_GUARD.guard(ctx, rule=rule) +print(json.dumps({'env': ctx.env, 'appEnv': ctx.app_env, 'rejected': result is not None, 'allowed': allowed is None})) +""" + process_env = {key: value for key, value in os.environ.items() if key != 'APP_ENV' and not key.startswith('OIDC_')} + process_env['PYTHONPATH'] = str(BACKEND_DIR) + process_env['DB_DEFAULT_SOURCE'] = 'master' + process_env['DB_SOURCES'] = json.dumps( + { + 'master': { + 'db_type': 'mysql', + 'db_host': '127.0.0.1', + 'db_port': 1, + 'db_username': 'test', + 'db_password': 'test', + 'db_database': 'isolated', + } + } + ) + if external_env: + process_env['APP_ENV'] = external_env + completed = subprocess.run( + [sys.executable, '-c', script, *(['--env', selected] if selected else [])], + cwd=tmp_path, + env=process_env, + capture_output=True, + text=True, + check=False, + timeout=15, + ) + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + 'env': expected_selector, + 'appEnv': expected_app, + 'rejected': rejected, + 'allowed': True, + } + + +def test_env_option_uses_external_default_and_preserves_explicit_choice(monkeypatch: pytest.MonkeyPatch) -> None: + app = typer.Typer() + selected = [] + + @app.command() + def run(env: EnvOption = 'dev') -> None: + selected.append(env) + + monkeypatch.setenv('APP_ENV', 'prod') + runner = CliRunner() + assert runner.invoke(app, []).exit_code == 0 + assert runner.invoke(app, ['--env', 'dockermy']).exit_code == 0 + assert selected == ['prod', 'dockermy'] diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py index 707dd7e1d..6218399a1 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_artifact_commands.py @@ -274,7 +274,6 @@ def __init__(self, events: list[str]) -> None: self.events = events def build_regular(self, env: str, output: str, allow_prod: bool, yes: bool, dry_run: bool) -> CliContext: - assert os.environ['APP_ENV'] == env self.events.append('context') return CliContext(env=env, output=output, allow_prod=allow_prod, yes=yes, dry_run=dry_run) @@ -310,11 +309,15 @@ def quiet_context_factory() -> CliContextFactory: ) -def test_production_mutations_are_guarded_before_loading_runtime() -> None: +def test_production_mutations_are_guarded_before_loading_runtime(monkeypatch: pytest.MonkeyPatch) -> None: """ 验证生产写入保护先于运行时加载执行。 """ + env_module = importlib.import_module('config.env') + monkeypatch.setattr(env_module.get_config, 'run_env', 'prod') + monkeypatch.setattr(env_module.AppConfig, 'app_env', 'prod') + def forbidden_config() -> None: pytest.fail('rejected CLI must not construct runtime') @@ -340,16 +343,17 @@ def forbidden_config() -> None: assert json.loads(result.stdout)['ok'] is False -def test_dry_run_is_readonly_and_environment_precedes_runtime() -> None: +def test_dry_run_is_readonly_and_environment_precedes_runtime(monkeypatch: pytest.MonkeyPatch) -> None: """ - 验证预演保持只读,且先设置环境再创建运行时。 + 验证预演保持只读,且不会用配置选择名覆盖外部应用环境。 """ + monkeypatch.setenv('APP_ENV', 'dev') events: list[str] = [] execution = RecordingExecution() def config() -> object: assert events == ['context'] - assert os.environ['APP_ENV'] == 'prod' + assert os.environ['APP_ENV'] == 'dev' events.append('config') return object() diff --git a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py index 96c5395d5..5b9be51fa 100644 --- a/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py +++ b/ruoyi-fastapi-backend/tests/cli/root/test_plugin_release_wait.py @@ -146,7 +146,7 @@ def test_wait_outputs_exactly_one_final_json_and_preserves_service_exit_semantic timeout_seconds=0.0, poll_interval_seconds=0.25, ) - assert os.environ['APP_ENV'] == 'prod' + assert os.environ['APP_ENV'] == 'dev' @pytest.mark.parametrize( diff --git a/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py b/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py index 6da16e82c..2f3b3c6f2 100644 --- a/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py +++ b/ruoyi-fastapi-backend/tests/cli/runtime/test_job.py @@ -1,8 +1,14 @@ +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + import pytest +from cli.output import OutputRenderer from cli.runtime.job import JobRuntimeService from cli.runtime.job.gateway import JobInfrastructureGateway from cli.runtime.job.support import JobDomainSupport, JobSchedulerSupport +from common.vo import CrudResponseModel MISSING_JOB_ID = 99 @@ -172,3 +178,39 @@ def _fake_get_job_service() -> FakeJobService: assert payload['ok'] is False assert payload['jobId'] == MISSING_JOB_ID assert payload['message'] == f'定时任务不存在:{MISSING_JOB_ID}' + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation', ['run-once', 'pause', 'resume']) +async def test_job_operation_retains_execution_and_sync_results( + operation: str, capsys: pytest.CaptureFixture[str] +) -> None: + result = ( + {'executionId': 'execution-123', 'status': 'pending'} + if operation == 'run-once' + else {'saved': True, 'synced': False, 'jobs': [{'jobId': 7, 'syncStatus': 'pending'}]} + ) + response = CrudResponseModel(is_success=True, message='已提交,等待调度器处理', result=result) + job_service = SimpleNamespace( + execute_job_once_services=AsyncMock(return_value=response), + edit_job_services=AsyncMock(return_value=response), + ) + gateway = MagicMock() + gateway.get_async_session_local.return_value = MagicMock(return_value=AsyncMock()) + gateway.get_redis_util.return_value.create_redis_pool = AsyncMock() + gateway.get_scheduler_manager.return_value.init_system_scheduler = AsyncMock() + gateway.get_job_service.return_value = job_service + scheduler_support = SimpleNamespace(close_scheduler_context=AsyncMock()) + service = JobRuntimeService(infrastructure_gateway=gateway, scheduler_support=scheduler_support) + payload = await service.run_with_scheduler_context(operation, 7) + assert payload['result'] == result + assert payload['message'] == response.message + OutputRenderer().emit_output(payload, 'json') + assert json.loads(capsys.readouterr().out)['result'] == result + text = OutputRenderer().render_text_output(payload) + if operation == 'run-once': + assert 'execution-123' in text + assert 'pending' in text + else: + assert 'saved' in text and 'synced' in text + scheduler_support.close_scheduler_context.assert_awaited_once() diff --git a/ruoyi-fastapi-backend/tests/common/test_data_scope.py b/ruoyi-fastapi-backend/tests/common/test_data_scope.py new file mode 100644 index 000000000..4d4e30b4c --- /dev/null +++ b/ruoyi-fastapi-backend/tests/common/test_data_scope.py @@ -0,0 +1,70 @@ +from types import SimpleNamespace + +import pytest +from fastapi import Request +from sqlalchemy import Column, Integer, MetaData, Table, create_engine, select +from sqlalchemy.orm import registry + +from common.aspect.data_scope import GetDataScope +from common.context import RequestContext +from module_admin.entity.vo.role_vo import RoleModel +from utils.dependency_util import DependencyUtil + + +@pytest.mark.parametrize( + ('roles', 'admin', 'expected'), + [ + ([], False, []), + ([('1', '1', '0')], False, []), + ([('1', '0', '2')], False, []), + ([(None, '0', '0')], False, []), + ([('5', '0', '0')], False, [1]), + ([('3', '0', '0')], False, [1, 2]), + ([('3', '0', '0'), ('5', '0', '0')], False, [1, 2]), + ([('5', '0', '0'), ('1', '1', '0')], False, [1]), + ([('1', '0', '0')], False, [1, 2, 3]), + ([], True, [1, 2, 3]), + ], +) +def test_data_scope_filters_real_query( + monkeypatch: pytest.MonkeyPatch, roles: list[tuple], admin: bool, expected: list[int] +) -> None: + metadata = MetaData() + table = Table('records', metadata, Column('user_id', Integer, primary_key=True), Column('dept_id', Integer)) + mapper = registry() + + class Record: + pass + + mapper.map_imperatively(Record, table) + user = SimpleNamespace( + user_id=1, + dept_id=10, + admin=admin, + role=[ + RoleModel(roleId=index + 2, dataScope=scope, status=status, delFlag=deleted) + for index, (scope, status, deleted) in enumerate(roles) + ], + ) + monkeypatch.setattr(RequestContext, 'get_current_user', lambda: SimpleNamespace(user=user)) + monkeypatch.setattr(DependencyUtil, 'check_exclude_routes', lambda *args, **kwargs: None) + condition = GetDataScope(Record)(Request({'type': 'http'})) + query = select(table.c.user_id).where(condition).order_by(table.c.user_id) + engine = create_engine('sqlite://') + metadata.create_all(engine) + try: + with engine.begin() as connection: + connection.execute( + table.insert(), + [ + {'user_id': 1, 'dept_id': 10}, + {'user_id': 2, 'dept_id': 10}, + {'user_id': 3, 'dept_id': 20}, + ], + ) + assert list(connection.scalars(query)) == expected + if not expected: + assert 'WHERE 0 = 1' in str(query.compile(engine)) + finally: + engine.dispose() + mapper.dispose() diff --git a/ruoyi-fastapi-backend/tests/common/test_exception_responses.py b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py new file mode 100644 index 000000000..29cbcc2e9 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/common/test_exception_responses.py @@ -0,0 +1,111 @@ +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI, HTTPException, Request +from fastapi.testclient import TestClient + +from common.annotation.log_annotation import Log +from common.constant import HttpStatusConstant +from common.context import RequestContext +from common.enums import BusinessType +from exceptions.exception import ( + AuthException, + FileRangeNotSatisfiableException, + LoginException, + OAuthProtocolException, + OidcInteractionException, + PermissionException, + ServiceException, +) +from exceptions.handle import handle_exception +from module_admin.service.log_service import LogQueueService +from utils.dependency_util import DependencyUtil + + +@pytest.fixture +def log_context(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + monkeypatch.setattr(DependencyUtil, 'check_exclude_routes', lambda *args, **kwargs: None) + monkeypatch.setattr(Log, '_get_oper_location', AsyncMock(return_value='测试')) + monkeypatch.setattr( + RequestContext, + 'get_current_user', + lambda: SimpleNamespace( + user=SimpleNamespace(user_name='tester', dept=None), + ), + ) + monkeypatch.setattr(LogQueueService, 'enqueue_operation_log', AsyncMock()) + diagnostic_logger = MagicMock() + monkeypatch.setattr('utils.exception_util.logger', diagnostic_logger) + return diagnostic_logger + + +@pytest.mark.parametrize('decorated', [False, True]) +def test_unexpected_error_is_safe_and_correlates_with_server_log(log_context: MagicMock, decorated: bool) -> None: + app = FastAPI() + handle_exception(app) + error = RuntimeError('SELECT secret FROM users; password=private-marker /srv/private/config') + + async def endpoint(request: Request) -> None: + raise error + + app.get('/failure')(Log(title='测试', business_type=BusinessType.OTHER)(endpoint) if decorated else endpoint) + response = TestClient(app, raise_server_exceptions=False).get('/failure') + payload = response.json() + assert payload['code'] == HttpStatusConstant.ERROR + assert payload['msg'] == '服务暂时不可用,请稍后重试' + assert 'private-marker' not in response.text + assert 'SELECT' not in response.text + assert '/srv/private' not in response.text + error_id = payload['data']['errorId'] + assert response.headers['X-Error-Id'] == error_id + log_context.bind.assert_called_once_with(error_id=error_id) + log_context.bind.return_value.opt.assert_called_once_with(exception=error) + + +@pytest.mark.parametrize( + 'exception_kind', ['auth', 'permission', 'http', 'oauth', 'redirect', 'oidc', 'login', 'service', 'range'] +) +@pytest.mark.parametrize('audit_fails', [False, True]) +def test_logged_known_errors_preserve_global_response( + log_context: MagicMock, monkeypatch: pytest.MonkeyPatch, exception_kind: str, audit_fails: bool +) -> None: + exceptions = { + 'auth': AuthException(message='令牌失效'), + 'permission': PermissionException(message='无权限'), + 'http': HTTPException(401, '凭证无效', headers={'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store'}), + 'oauth': OAuthProtocolException('invalid_client'), + 'redirect': OAuthProtocolException( + 'access_denied', redirect_uri='https://client.example/callback', redirect_uri_verified=True, state='opaque' + ), + 'oidc': OidcInteractionException(interaction_id='expired'), + 'login': LoginException(message='登录失败'), + 'service': ServiceException(message='操作失败'), + 'range': FileRangeNotSatisfiableException(20), + } + app = FastAPI() + handle_exception(app) + + async def endpoint(request: Request) -> None: + raise exceptions[exception_kind] + + app.get('/plain')(endpoint) + app.get('/logged')(Log(title='测试', business_type=BusinessType.OTHER)(endpoint)) + if audit_fails: + monkeypatch.setattr( + LogQueueService, 'enqueue_operation_log', AsyncMock(side_effect=RuntimeError('queue unavailable')) + ) + client = TestClient(app, raise_server_exceptions=False) + plain = client.get('/plain', follow_redirects=False) + logged = client.get('/logged', follow_redirects=False) + assert logged.status_code == plain.status_code + for name in ['location', 'www-authenticate', 'cache-control', 'pragma', 'content-range']: + assert logged.headers.get(name) == plain.headers.get(name) + if plain.content: + expected, actual = plain.json(), logged.json() + expected.pop('time', None) + actual.pop('time', None) + assert actual == expected + else: + assert not logged.content + LogQueueService.enqueue_operation_log.assert_awaited_once() diff --git a/ruoyi-fastapi-backend/tests/module_admin/controller/test_dept_controller.py b/ruoyi-fastapi-backend/tests/module_admin/controller/test_dept_controller.py new file mode 100644 index 000000000..63dbbdf77 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/controller/test_dept_controller.py @@ -0,0 +1,74 @@ +import inspect +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from exceptions.exception import ServiceException +from module_admin.controller.dept_controller import add_system_dept, edit_system_dept +from module_admin.entity.vo.dept_vo import DeptModel +from module_admin.service.dept_service import DeptService + + +@pytest.mark.asyncio +async def test_add_dept_rejects_parent_outside_data_scope() -> None: + current_user = SimpleNamespace(user=SimpleNamespace(admin=False, user_name='operator')) + parent_id = 200 + with ( + patch.object( + DeptService, + 'check_dept_data_scope_services', + AsyncMock(side_effect=ServiceException(message='没有权限访问部门数据')), + ) as check_scope, + patch.object(DeptService, 'add_dept_services', AsyncMock()) as add_dept, + pytest.raises(ServiceException), + ): + await inspect.unwrap(add_system_dept)( + object(), DeptModel(parentId=parent_id, deptName='新部门'), object(), current_user, object() + ) + assert check_scope.await_args.args[1] == parent_id + add_dept.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize(('parent_id', 'parent_changed'), [(100, False), (200, True)]) +async def test_edit_dept_checks_changed_parent_scope(parent_id: int, parent_changed: bool) -> None: + current_user = SimpleNamespace(user=SimpleNamespace(admin=False, user_name='operator')) + query_db, scope = object(), object() + with ( + patch.object( + DeptService, + 'check_dept_data_scope_services', + AsyncMock(side_effect=[None, ServiceException(message='没有权限访问部门数据')]), + ) as check_scope, + patch.object(DeptService, 'dept_detail_services', AsyncMock(return_value=DeptModel(deptId=101, parentId=100))), + patch.object( + DeptService, 'edit_dept_services', AsyncMock(return_value=SimpleNamespace(message='更新成功')) + ) as edit_dept, + ): + args = (object(), DeptModel(deptId=101, parentId=parent_id), query_db, current_user, scope) + if parent_changed: + with pytest.raises(ServiceException): + await inspect.unwrap(edit_system_dept)(*args) + assert [call.args[1] for call in check_scope.await_args_list] == [101, 200] + edit_dept.assert_not_awaited() + else: + await inspect.unwrap(edit_system_dept)(*args) + check_scope.assert_awaited_once_with(query_db, 101, scope) + edit_dept.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_admin_add_dept_does_not_require_parent_data_scope() -> None: + current_user = SimpleNamespace(user=SimpleNamespace(admin=True, user_name='admin')) + with ( + patch.object(DeptService, 'check_dept_data_scope_services', AsyncMock()) as check_scope, + patch.object( + DeptService, 'add_dept_services', AsyncMock(return_value=SimpleNamespace(message='新增成功')) + ) as add_dept, + ): + await inspect.unwrap(add_system_dept)( + object(), DeptModel(parentId=100, deptName='新部门'), object(), current_user, object() + ) + check_scope.assert_not_awaited() + add_dept.assert_awaited_once() diff --git a/ruoyi-fastapi-backend/tests/module_admin/controller/test_user_controller.py b/ruoyi-fastapi-backend/tests/module_admin/controller/test_user_controller.py index 94ac54b2e..679f13925 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/controller/test_user_controller.py +++ b/ruoyi-fastapi-backend/tests/module_admin/controller/test_user_controller.py @@ -6,7 +6,11 @@ from pydantic import ValidationError from common.enums import PasswordCharacterType -from module_admin.controller.user_controller import change_system_user_profile_info, reset_system_user_pwd +from module_admin.controller.user_controller import ( + change_system_user_profile_info, + edit_system_user, + reset_system_user_pwd, +) from module_admin.entity.vo.user_vo import EditUserModel, UpdateUserProfileModel from module_admin.service.user_service import UserService from utils.pwd_util import PwdUtil @@ -116,3 +120,35 @@ async def test_update_user_profile_only_forwards_editable_fields() -> None: 'create_by', 'create_time', }.intersection(update_fields) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('operation_type', [None, 'pwd', 'status', 'avatar']) +async def test_edit_user_does_not_accept_password_or_internal_operation_type(operation_type: str | None) -> None: + """普通编辑权限不能调用密码重置路径或写入内部维护字段。""" + payload = EditUserModel( + userId=2, + deptId=100, + userName='tester', + nickName='新昵称', + roleIds=[2], + postIds=[3], + password='injected-password-hash', + type=operation_type, + delFlag='2', + loginIp='forged-ip', + createBy='forged-creator', + ) + current_user = SimpleNamespace(user=SimpleNamespace(admin=True, user_name='admin')) + with patch.object( + UserService, 'edit_user_services', AsyncMock(return_value=SimpleNamespace(message='更新成功')) + ) as edit_user: + await inspect.unwrap(edit_system_user)(object(), payload, object(), current_user, object(), object(), object()) + + update = edit_user.await_args.args[1] + fields = update.model_dump(exclude_unset=True) + assert not {'password', 'type', 'del_flag', 'login_ip', 'create_by'}.intersection(fields) + assert update.nick_name == '新昵称' + assert update.role_ids == [2] + assert update.post_ids == [3] + assert update.update_by == 'admin' diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_dept_service.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_dept_service.py index 13da67bdf..a27e2bb1b 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_dept_service.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_dept_service.py @@ -1,20 +1,26 @@ from types import SimpleNamespace +from typing import Any from unittest.mock import AsyncMock, patch import pytest -from sqlalchemy import select +from sqlalchemy import event, select from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from config.database import Base from exceptions.exception import ServiceException from module_admin.dao.dept_dao import DeptDao from module_admin.entity.do.dept_do import SysDept -from module_admin.entity.vo.dept_vo import DeptSortModel +from module_admin.entity.vo.dept_vo import DeptModel, DeptSortModel from module_admin.service.dept_service import DeptService async def _create_dept_table() -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: engine = create_async_engine('sqlite+aiosqlite:///:memory:') + + @event.listens_for(engine.sync_engine, 'connect') + def register_find_in_set(connection: Any, _record: Any) -> None: + connection.create_function('find_in_set', 2, lambda value, values: int(str(value) in (values or '').split(','))) + session_maker = async_sessionmaker(bind=engine, expire_on_commit=False) async with engine.begin() as connection: await connection.run_sync(Base.metadata.create_all, tables=[SysDept.__table__]) @@ -88,3 +94,98 @@ async def test_update_dept_sort_rolls_back_and_wraps_database_error() -> None: assert exc_info.value.message == '保存排序异常,请联系管理员' query_db.commit.assert_not_awaited() query_db.rollback.assert_awaited_once() + + +async def _seed_dept_tree(session: AsyncSession) -> None: + session.add_all( + [ + SysDept(dept_id=100, parent_id=0, ancestors='0', dept_name='总部门'), + SysDept(dept_id=101, parent_id=100, ancestors='0,100', dept_name='研发部门'), + SysDept(dept_id=102, parent_id=101, ancestors='0,100,101', dept_name='研发小组'), + SysDept(dept_id=103, parent_id=102, ancestors='0,100,101,102', dept_name='研发子组'), + SysDept(dept_id=200, parent_id=100, ancestors='0,100', dept_name='测试部门'), + SysDept(dept_id=201, parent_id=100, ancestors='0,100', dept_name='已删除部门', del_flag='2'), + ] + ) + await session.commit() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [101, 102, 103, 201, 999]) +async def test_edit_dept_rejects_cycles_and_invalid_parents_without_writing(parent_id: int) -> None: + engine, sessions = await _create_dept_table() + try: + async with sessions() as session: + await _seed_dept_tree(session) + query = select(SysDept.dept_id, SysDept.parent_id, SysDept.ancestors).order_by(SysDept.dept_id) + before = (await session.execute(query)).all() + with pytest.raises(ServiceException): + await DeptService.edit_dept_services( + session, DeptModel(deptId=101, parentId=parent_id, deptName='研发部门', orderNum=1) + ) + assert (await session.execute(query)).all() == before + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [0, 200]) +async def test_edit_dept_moves_whole_subtree_and_rebuilds_ancestors(parent_id: int) -> None: + engine, sessions = await _create_dept_table() + try: + async with sessions() as session: + await _seed_dept_tree(session) + result = await DeptService.edit_dept_services( + session, + DeptModel(deptId=101, parentId=parent_id, deptName='研发部门', ancestors='forged', orderNum=1), + ) + ancestor_prefix = '0' if parent_id == 0 else '0,100,200' + rows = ( + await session.execute( + select(SysDept.dept_id, SysDept.parent_id, SysDept.ancestors) + .where(SysDept.dept_id.in_([101, 102, 103])) + .order_by(SysDept.dept_id) + ) + ).all() + assert result.is_success + assert rows == [ + (101, parent_id, ancestor_prefix), + (102, 101, f'{ancestor_prefix},101'), + (103, 102, f'{ancestor_prefix},101,102'), + ] + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('dept_id', [100, 101]) +async def test_edit_dept_preserves_omitted_parent_and_ignores_supplied_ancestors(dept_id: int) -> None: + engine, sessions = await _create_dept_table() + try: + async with sessions() as session: + await _seed_dept_tree(session) + query = select(SysDept.parent_id, SysDept.ancestors).where(SysDept.dept_id == dept_id) + before = (await session.execute(query)).one() + await DeptService.edit_dept_services( + session, DeptModel(deptId=dept_id, deptName='新名称', ancestors='0,999', orderNum=1) + ) + assert (await session.execute(query)).one() == before + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [None, 201, 999]) +async def test_add_dept_rejects_missing_or_deleted_parent(parent_id: int | None) -> None: + engine, sessions = await _create_dept_table() + try: + async with sessions() as session: + await _seed_dept_tree(session) + with pytest.raises(ServiceException) as exc_info: + await DeptService.add_dept_services( + session, DeptModel(deptId=300, parentId=parent_id, deptName='新部门', orderNum=1) + ) + assert exc_info.value.message == '上级部门不存在' + assert await session.get(SysDept, 300) is None + finally: + await engine.dispose() diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py index 17ad1944b..ded604c2b 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_file_management.py @@ -75,7 +75,7 @@ def test_file_data_scope_does_not_match_unowned_files_when_user_has_no_departmen user_id=20, dept_id=None, admin=False, - role=[SimpleNamespace(role_id=2, data_scope=data_scope)], + role=[SimpleNamespace(role_id=2, data_scope=data_scope, status='0', del_flag='0')], ) ) diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_menu_service.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_menu_service.py index 2e9b6ddd6..0d6e1e062 100644 --- a/ruoyi-fastapi-backend/tests/module_admin/service/test_menu_service.py +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_menu_service.py @@ -3,7 +3,7 @@ import pytest from sqlalchemy import select -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from common.constant import CommonConstant from config.database import Base @@ -222,3 +222,65 @@ async def test_update_menu_sort_rolls_back_and_wraps_database_error() -> None: assert exc_info.value.message == '保存排序异常,请联系管理员' query_db.commit.assert_not_awaited() query_db.rollback.assert_awaited_once() + + +async def _create_menu_tree() -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]: + engine = create_async_engine('sqlite+aiosqlite:///:memory:') + sessions = async_sessionmaker(bind=engine, expire_on_commit=False) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all, tables=[SysMenu.__table__]) + async with sessions() as session: + session.add_all( + [ + SysMenu(menu_id=501, parent_id=0, menu_name='系统目录', menu_type='M', path='system'), + SysMenu(menu_id=502, parent_id=501, menu_name='用户菜单', menu_type='C', path='user'), + SysMenu(menu_id=503, parent_id=502, menu_name='新增按钮', menu_type='F'), + SysMenu(menu_id=600, parent_id=0, menu_name='工具目录', menu_type='M', path='tools'), + SysMenu(menu_id=701, parent_id=702, menu_name='异常目录一', menu_type='M'), + SysMenu(menu_id=702, parent_id=701, menu_name='异常目录二', menu_type='M'), + SysMenu(menu_id=800, parent_id=999, menu_name='孤立目录', menu_type='M'), + ] + ) + await session.commit() + return engine, sessions + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [501, 502, 503, 701, 800, 999]) +async def test_edit_menu_rejects_invalid_parent_chains_without_writing(parent_id: int) -> None: + engine, sessions = await _create_menu_tree() + try: + async with sessions() as session: + query = select(SysMenu.menu_id, SysMenu.parent_id).order_by(SysMenu.menu_id) + before = (await session.execute(query)).all() + with pytest.raises(ServiceException): + await MenuService.edit_menu_services( + session, + MenuModel(menuId=501, parentId=parent_id, menuName='系统目录', menuType='M', path='system'), + ) + assert (await session.execute(query)).all() == before + finally: + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.parametrize('parent_id', [None, 0, 600]) +async def test_edit_menu_preserves_root_or_moves_subtree_to_valid_parent(parent_id: int | None) -> None: + engine, sessions = await _create_menu_tree() + try: + async with sessions() as session: + result = await MenuService.edit_menu_services( + session, + MenuModel(menuId=501, parentId=parent_id, menuName='新系统目录', menuType='M', path='system'), + ) + rows = ( + await session.execute( + select(SysMenu.menu_id, SysMenu.parent_id) + .where(SysMenu.menu_id.in_([501, 502, 503])) + .order_by(SysMenu.menu_id) + ) + ).all() + assert result.is_success + assert rows == [(501, parent_id or 0), (502, 501), (503, 502)] + finally: + await engine.dispose() diff --git a/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py b/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py new file mode 100644 index 000000000..9f8b4e339 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_admin/service/test_notice_html_security.py @@ -0,0 +1,88 @@ +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from module_admin.dao.notice_dao import NoticeDao +from module_admin.entity.do.notice_do import SysNotice +from module_admin.entity.vo.notice_vo import NoticeModel, NoticePageQueryModel +from module_admin.service.notice_service import NoticeService + +PAYLOAD = '

正文重点

链接' + + +def assert_safe(content: str) -> None: + assert '重点' in content + assert 'src="/safe.png"' in content + assert 'onerror' not in content + assert ' None: + app = FastAPI() + + @app.post('/notice') + def notice(payload: NoticeModel) -> NoticeModel: + assert_safe(payload.notice_content.decode()) + return payload + + response = TestClient(app).post('/notice', json={'noticeTitle': '测试', 'noticeContent': PAYLOAD}) + assert_safe(response.json()['noticeContent']) + + +@pytest.mark.asyncio +async def test_legacy_notice_detail_and_list_are_sanitized(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + NoticeDao, + 'get_notice_detail_by_id', + AsyncMock( + return_value=SysNotice( + notice_id=2, + notice_title='历史公告', + notice_content=PAYLOAD.encode(), + ) + ), + ) + monkeypatch.setattr(NoticeDao, 'get_notice_list', AsyncMock(return_value=[{'noticeContent': PAYLOAD}])) + result = await NoticeService.notice_detail_services(AsyncMock(), 2) + assert_safe(result.notice_content.decode()) + rows = await NoticeService.get_notice_list_services(AsyncMock(), NoticePageQueryModel(), False) + assert_safe(rows[0]['noticeContent']) + + +@pytest.mark.parametrize('url', ['javascript:alert(1)', 'javascript:alert(1)', 'data:text/html,x', 'vbscript:x']) +def test_rejects_dangerous_urls_and_embedded_content(url: str) -> None: + result = NoticeModel(noticeContent=f'x') + content = result.notice_content.decode() + assert 'href=' not in content + assert 'src=' not in content + assert ' None: + content = '

标题

表格
' + result = NoticeModel(noticeContent=content).notice_content.decode() + assert '

标题

' in result and '正文' in result and 'colspan="2"' in result + assert 'notice_content' not in NoticeModel(status='1').model_dump(exclude_unset=True) + + +def test_quill_text_styles_are_preserved_without_layout_styles() -> None: + content = NoticeModel( + noticeContent='

正文

' + ).notice_content.decode() + assert 'ql-align-center' in content + assert 'color:red' in content and 'background-color:yellow' in content + assert 'position' not in content + + +def test_quill_video_is_sandboxed_and_cannot_embed_inline_script() -> None: + content = NoticeModel( + noticeContent='' + ).notice_content.decode() + assert 'src="https://video.example/embed/1"' in content + assert 'sandbox="allow-scripts allow-presentation"' in content + assert 'srcdoc' not in content and 'allow-same-origin' not in content + content = NoticeModel(noticeContent='正文').notice_content.decode() + assert 'expression' not in content diff --git a/ruoyi-fastapi-backend/tests/module_identity/controllers/conftest.py b/ruoyi-fastapi-backend/tests/module_identity/controllers/conftest.py new file mode 100644 index 000000000..6b14ab389 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/module_identity/controllers/conftest.py @@ -0,0 +1,42 @@ +from collections.abc import Callable +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from fastapi import APIRouter, FastAPI +from fastapi.routing import APIRoute +from httpx import ASGITransport, AsyncClient + +from common.annotation.log_annotation import Log +from common.annotation.rate_limit_annotation import ApiRateLimit +from exceptions.handle import handle_exception + + +@pytest.fixture +def oauth_management_client(monkeypatch: pytest.MonkeyPatch) -> Callable[[APIRouter, object, object], AsyncClient]: + """构造使用真实路由和全局异常处理器的管理端 HTTP 客户端。""" + monkeypatch.setattr(Log, '_get_oper_location', AsyncMock(return_value='测试')) + monkeypatch.setattr( + ApiRateLimit, '_acquire_rate_limit', AsyncMock(return_value={'allowed': True, 'remaining': 1, 'reset_at': 0}) + ) + + def create_client(router: APIRouter, session: object, current_user: object) -> AsyncClient: + """替换数据库和认证依赖,保留控制器、装饰器及异常响应链。""" + app = FastAPI() + app.state.redis = SimpleNamespace() + app.state.oidc_registered_cors_origins = ('https://old.example',) + handle_exception(app) + app.include_router(router) + for route in router.routes: + if not isinstance(route, APIRoute): + continue + for dependency in route.dependant.dependencies: + if dependency.name == 'query_db': + app.dependency_overrides[dependency.call] = lambda: session + elif dependency.name == 'current_user': + app.dependency_overrides[dependency.call] = lambda: current_user + else: + app.dependency_overrides[dependency.call] = lambda: None + return AsyncClient(transport=ASGITransport(app=app), base_url='http://testserver') + + return create_client diff --git a/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_client_controller.py b/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_client_controller.py index 262022ffe..2b2775aa9 100644 --- a/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_client_controller.py +++ b/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_client_controller.py @@ -1,20 +1,19 @@ +from collections.abc import Callable +from http import HTTPStatus from types import SimpleNamespace import pytest -from fastapi import Request +from fastapi import APIRouter, Request from fastapi.routing import APIRoute -from starlette.responses import Response +from httpx import AsyncClient from common.context import RequestContext from module_admin.service.log_service import LogQueueService from module_identity.controller.oauth_client_controller import ( - add_system_oauth_client, - delete_system_oauth_clients, get_system_oauth_client_list, oauth_client_controller, - rotate_system_oauth_client_secret, ) -from module_identity.entity.vo.oauth_client_vo import ClientSecretResponseModel, SecretRotationModel +from module_identity.entity.vo.oauth_client_vo import ClientSecretResponseModel from module_identity.service.oauth_management_service import ( OAuthClientManagementError, OAuthClientManagementService, @@ -52,6 +51,16 @@ def _user() -> SimpleNamespace: return SimpleNamespace(user=SimpleNamespace(user_name='admin', dept=SimpleNamespace(dept_name='测试部门'))) +def _client_payload() -> dict[str, object]: + """构造通过请求模型校验的客户端创建参数。""" + return { + 'clientName': '测试客户端', + 'clientType': 'public', + 'tokenEndpointAuthMethod': 'none', + 'redirectUris': ['https://client.example/callback'], + } + + def _request() -> Request: """构造带可观察 CORS 快照的请求。""" app = SimpleNamespace(state=SimpleNamespace(oidc_registered_cors_origins=('https://old.example',))) @@ -101,17 +110,22 @@ def test_client_router_paths_methods_permissions_and_pre_auth() -> None: @pytest.mark.asyncio -async def test_client_create_commits_and_secret_is_only_in_rotation_response(monkeypatch: pytest.MonkeyPatch) -> None: - """成功写操作提交事务,轮换响应含明文而普通错误不包含敏感字段。""" +async def test_client_create_commits_and_secret_is_only_in_rotation_response( + monkeypatch: pytest.MonkeyPatch, + oauth_management_client: Callable[[APIRouter, object, object], AsyncClient], +) -> None: + """写操作由服务层管理事务,轮换响应含明文而普通错误不包含敏感字段。""" session = _FakeSession() - payload = SimpleNamespace() async def fake_create(*args: object, **kwargs: object) -> object: return SimpleNamespace(model_dump=lambda **_: {'clientId': 'cli_test'}) monkeypatch.setattr(OAuthClientManagementService, 'create_client', fake_create) - response = await add_system_oauth_client(_request(), payload, session, _user()) - assert isinstance(response, Response) + async with oauth_management_client(oauth_client_controller, session, _user()) as client: + response = await client.post('/system/oauth/client', json=_client_payload()) + assert response.status_code == HTTPStatus.OK + assert response.json()['success'] is True + assert b'cs1.' not in response.content assert session.commits == 0 and session.rollbacks == 0 secret = ClientSecretResponseModel( @@ -126,24 +140,32 @@ async def fake_rotate(*args: object, **kwargs: object) -> ClientSecretResponseMo return secret monkeypatch.setattr(OAuthClientManagementService, 'rotate_secret', fake_rotate) - rotation_response = await rotate_system_oauth_client_secret( - _request(), 'cli_test', session, _user(), SecretRotationModel() - ) - assert b'cs1.one-time-secret' in rotation_response.body + async with oauth_management_client(oauth_client_controller, session, _user()) as client: + rotation_response = await client.post('/system/oauth/client/cli_test/secret', json={}) + assert rotation_response.status_code == HTTPStatus.OK + assert rotation_response.json()['success'] is True + assert b'cs1.one-time-secret' in rotation_response.content assert session.commits == 0 async def fake_fail(*args: object, **kwargs: object) -> object: raise OAuthClientManagementError('OAuth Client request rejected') monkeypatch.setattr(OAuthClientManagementService, 'create_client', fake_fail) - failed = await add_system_oauth_client(_request(), payload, session, _user()) - assert b'secret_hash' not in failed.body - assert b'cs1.' not in failed.body + async with oauth_management_client(oauth_client_controller, session, _user()) as client: + failed = await client.post('/system/oauth/client', json=_client_payload()) + assert failed.status_code == HTTPStatus.OK + assert failed.json()['success'] is False + assert failed.json()['msg'] == 'OAuth Client request rejected' + assert b'secret_hash' not in failed.content + assert b'cs1.' not in failed.content assert session.rollbacks == 0 @pytest.mark.asyncio -async def test_client_batch_failure_rolls_back_all_items(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_client_batch_failure_rolls_back_all_items( + monkeypatch: pytest.MonkeyPatch, + oauth_management_client: Callable[[APIRouter, object, object], AsyncClient], +) -> None: """批量停用任一项失败时整体回滚,不提前提交。""" session = _FakeSession() calls: list[str] = [] @@ -156,8 +178,11 @@ async def fake_disable(*args: object, **kwargs: object) -> object: return SimpleNamespace() monkeypatch.setattr(OAuthClientManagementService, '_soft_disable', fake_disable) - response = await delete_system_oauth_clients(_request(), 'cli_good,cli_bad', session, _user()) - assert b'false' in response.body + async with oauth_management_client(oauth_client_controller, session, _user()) as client: + response = await client.delete('/system/oauth/client/cli_good,cli_bad') + assert response.status_code == HTTPStatus.OK + assert response.json()['success'] is False + assert response.json()['msg'] == 'client not found' assert calls == ['cli_good', 'cli_bad'] assert session.commits == 0 assert session.rollbacks == 1 @@ -179,6 +204,7 @@ async def fail_refresh(*args: object, **kwargs: object) -> tuple[str, ...]: @pytest.mark.asyncio async def test_client_list_uses_real_total_and_actor_failure_is_mapped( monkeypatch: pytest.MonkeyPatch, + oauth_management_client: Callable[[APIRouter, object, object], AsyncClient], ) -> None: """列表返回服务层全量计数,actor 缺失时稳定返回业务失败。""" session = _FakeSession() @@ -195,7 +221,10 @@ async def fake_count(*args: object, **kwargs: object) -> int: assert b'"total":37' in response.body assert b'"rows":["row-1","row-2"]' in response.body - failed = await add_system_oauth_client(_request(), SimpleNamespace(), session, SimpleNamespace(user=None)) - assert b'false' in failed.body - assert b'actor' not in failed.body + async with oauth_management_client(oauth_client_controller, session, SimpleNamespace(user=None)) as client: + failed = await client.post('/system/oauth/client', json=_client_payload()) + assert failed.status_code == HTTPStatus.OK + assert failed.json()['success'] is False + assert failed.json()['msg'] == '当前操作者不可用' + assert b'actor' not in failed.content assert session.rollbacks == 0 diff --git a/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_resource_controller.py b/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_resource_controller.py index a0ff756dc..da131c182 100644 --- a/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_resource_controller.py +++ b/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_resource_controller.py @@ -1,15 +1,16 @@ +from collections.abc import Callable +from http import HTTPStatus from types import SimpleNamespace +from urllib.parse import quote import pytest -from fastapi import Request +from fastapi import APIRouter from fastapi.routing import APIRoute -from starlette.responses import Response +from httpx import AsyncClient from common.context import RequestContext from module_admin.service.log_service import LogQueueService from module_identity.controller.oauth_resource_controller import ( - delete_system_oauth_resources, - delete_system_oauth_scopes, get_system_oauth_resource_list, get_system_oauth_scope_list, oauth_resource_controller, @@ -51,26 +52,6 @@ def _user() -> SimpleNamespace: return SimpleNamespace(user=SimpleNamespace(user_name='admin', dept=SimpleNamespace(dept_name='测试部门'))) -def _request() -> Request: - """构造管理写请求。""" - app = SimpleNamespace(state=SimpleNamespace()) - - async def receive() -> dict[str, object]: - return {'type': 'http.request', 'body': b'', 'more_body': False} - - return Request( - { - 'type': 'http', - 'method': 'POST', - 'path': '/', - 'headers': [], - 'query_string': b'', - 'app': app, - }, - receive=receive, - ) - - def _route_map(router: object) -> dict[tuple[str, str], APIRoute]: """构造路由路径和方法索引。""" routes = [route for route in router.routes if isinstance(route, APIRoute)] @@ -115,7 +96,10 @@ def test_resource_and_scope_router_paths_methods_permissions_and_pre_auth() -> N @pytest.mark.asyncio -async def test_resource_batch_failure_rolls_back_without_partial_commit(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_resource_batch_failure_rolls_back_without_partial_commit( + monkeypatch: pytest.MonkeyPatch, + oauth_management_client: Callable[[APIRouter, object, object], AsyncClient], +) -> None: """Resource 批量停用任一项失败时整体回滚。""" session = _FakeSession() calls: list[str] = [] @@ -128,22 +112,34 @@ async def fake_disable(*args: object, **kwargs: object) -> object: return SimpleNamespace() monkeypatch.setattr(OAuthResourceManagementService, '_soft_disable_resource', fake_disable) - response = await delete_system_oauth_resources(_request(), 'resource-good,resource-bad', session, _user()) - assert isinstance(response, Response) - assert b'false' in response.body + async with oauth_management_client(oauth_resource_controller, session, _user()) as client: + response = await client.delete('/system/oauth/resource/resource-good,resource-bad') + assert response.status_code == HTTPStatus.OK + assert response.json()['success'] is False + assert response.json()['msg'] == 'resource not found' assert calls == ['resource-good', 'resource-bad'] assert session.commits == 0 assert session.rollbacks == 1 @pytest.mark.asyncio -async def test_scope_batch_rejects_empty_duplicate_and_invalid_encoding() -> None: +async def test_scope_batch_rejects_empty_duplicate_and_invalid_encoding( + oauth_management_client: Callable[[APIRouter, object, object], AsyncClient], +) -> None: """Scope 批量路径拒绝空项、重复项和未解析的百分号编码。""" session = _FakeSession() user = _user() - for value in ('scope-a,,scope-b', 'scope-a,scope-a', 'scope-a%2Fb'): - response = await delete_system_oauth_scopes(_request(), value, session, user) - assert b'false' in response.body + async with oauth_management_client(oauth_scope_controller, session, user) as client: + for value, message in ( + ('scope-a,,scope-b', 'scope_codes 参数无效'), + ('scope-a,scope-a', 'scope_codes 参数重复'), + ('scope-a%2Fb', 'scope_codes 参数无效'), + ): + # 编码百分号,确保路由解码后仍将未解析的编码传给批量参数校验。 + response = await client.delete(f'/system/oauth/scope/{quote(value, safe=",")}') + assert response.status_code == HTTPStatus.OK + assert response.json()['success'] is False + assert response.json()['msg'] == message assert session.commits == 0 assert session.rollbacks == 0 @@ -151,6 +147,7 @@ async def test_scope_batch_rejects_empty_duplicate_and_invalid_encoding() -> Non @pytest.mark.asyncio async def test_resource_scope_lists_use_real_total_and_actor_failure_is_mapped( monkeypatch: pytest.MonkeyPatch, + oauth_management_client: Callable[[APIRouter, object, object], AsyncClient], ) -> None: """Resource/Scope 列表使用真实全量计数,缺少 actor 时不逃逸异常。""" session = _FakeSession() @@ -173,7 +170,10 @@ async def fake_count(*args: object, **kwargs: object) -> int: assert b'"total":37' in scope_response.body assert b'"rows":["row-1","row-2"]' in scope_response.body - failed = await delete_system_oauth_resources(_request(), 'resource-a', session, SimpleNamespace(user=None)) - assert b'false' in failed.body - assert b'actor' not in failed.body + async with oauth_management_client(oauth_resource_controller, session, SimpleNamespace(user=None)) as client: + failed = await client.delete('/system/oauth/resource/resource-a') + assert failed.status_code == HTTPStatus.OK + assert failed.json()['success'] is False + assert failed.json()['msg'] == '当前操作者不可用' + assert b'actor' not in failed.content assert session.rollbacks == 0 diff --git a/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_session_controller.py b/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_session_controller.py index c88406276..9e5586c58 100644 --- a/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_session_controller.py +++ b/ruoyi-fastapi-backend/tests/module_identity/controllers/test_oauth_session_controller.py @@ -1,10 +1,11 @@ import json +from collections.abc import Callable from http import HTTPStatus from types import SimpleNamespace from unittest.mock import AsyncMock import pytest -from fastapi import FastAPI, Request +from fastapi import APIRouter, FastAPI, Request from fastapi.routing import APIRoute from httpx import ASGITransport, AsyncClient @@ -92,7 +93,10 @@ def test_session_and_grant_routes_have_pre_auth_and_exact_permissions() -> None: @pytest.mark.asyncio -async def test_batch_revoke_is_atomic_and_reason_does_not_leak(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_batch_revoke_is_atomic_and_reason_does_not_leak( + monkeypatch: pytest.MonkeyPatch, + oauth_management_client: Callable[[APIRouter, object, object], AsyncClient], +) -> None: """批量撤销失败时回滚且不回显内部敏感字段。""" db = _Session() @@ -114,13 +118,14 @@ async def revoke(*args: object, **kwargs: object) -> bool: monkeypatch.setattr(SsoSessionService, 'revoke', revoke) token = RequestContext.set_current_user(_user()) try: - response = await revoke_oauth_sessions.__wrapped__( - _request(), 'good,bad', SimpleNamespace(reason='管理员操作'), db, _user() - ) + async with oauth_management_client(oauth_session_controller, db, _user()) as client: + response = await client.request('DELETE', '/system/oauth/session/good,bad', json={'reason': '管理员操作'}) finally: RequestContext.reset_current_user(token) - assert b'false' in response.body - assert b'refresh_token_hash' not in response.body + assert response.status_code == HTTPStatus.OK + assert response.json()['success'] is False + assert b'refresh_token_hash' not in response.content + assert b'do-not-leak' not in response.content assert db.commits == 0 and db.rollbacks == 1 async def grant_revoke(*args: object, **kwargs: object) -> bool: diff --git a/ruoyi-fastapi-backend/tests/test_api_docs_switches.py b/ruoyi-fastapi-backend/tests/test_api_docs_switches.py new file mode 100644 index 000000000..70d2de244 --- /dev/null +++ b/ruoyi-fastapi-backend/tests/test_api_docs_switches.py @@ -0,0 +1,45 @@ +import pytest +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from config.env import AppConfig +from utils.server_util import APIDocsUtil + + +@pytest.mark.parametrize('disable_swagger', [False, True]) +@pytest.mark.parametrize('disable_redoc', [False, True]) +def test_custom_schema_respects_docs_switches( + monkeypatch: pytest.MonkeyPatch, disable_swagger: bool, disable_redoc: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_disable_swagger', disable_swagger) + monkeypatch.setattr(AppConfig, 'app_disable_redoc', disable_redoc) + app = FastAPI(openapi_url=None) + APIDocsUtil.custom_api_docs_router(app) + response = TestClient(app).get('/openapi.json') + assert response.status_code == (404 if disable_swagger and disable_redoc else 200) + if response.status_code == status.HTTP_200_OK: + assert 'openapi' in response.json() + + +@pytest.mark.parametrize('disable_swagger', [False, True]) +@pytest.mark.parametrize('disable_redoc', [False, True]) +def test_proxy_schema_and_enabled_ui_are_available( + monkeypatch: pytest.MonkeyPatch, disable_swagger: bool, disable_redoc: bool +) -> None: + monkeypatch.setattr(AppConfig, 'app_disable_swagger', disable_swagger) + monkeypatch.setattr(AppConfig, 'app_disable_redoc', disable_redoc) + app = FastAPI( + openapi_url=APIDocsUtil.proxy_openapi_url(), + docs_url=APIDocsUtil.proxy_docs_url(), + redoc_url=APIDocsUtil.proxy_redoc_url(), + ) + APIDocsUtil.custom_api_docs_router(app) + client = TestClient(app) + assert client.get('/proxy-openapi.json').status_code == (404 if disable_swagger and disable_redoc else 200) + for path, disabled in [('/proxy-docs', disable_swagger), ('/proxy-redoc', disable_redoc)]: + response = client.get(path) + assert response.status_code == status.HTTP_200_OK + if not disabled: + assert '/proxy-openapi.json' in response.text + else: + assert 'has been disabled' in response.text diff --git a/ruoyi-fastapi-backend/utils/exception_util.py b/ruoyi-fastapi-backend/utils/exception_util.py new file mode 100644 index 000000000..2dc5b2137 --- /dev/null +++ b/ruoyi-fastapi-backend/utils/exception_util.py @@ -0,0 +1,28 @@ +from uuid import uuid4 + +from fastapi import Response + +from utils.log_util import logger +from utils.response_util import ResponseUtil + + +class ExceptionUtil: + """ + 未知异常的安全响应与诊断工具类。 + """ + + @staticmethod + def handle_unexpected_exception(exc: Exception) -> Response: + """ + 记录脱敏诊断并返回不包含内部异常信息的统一响应。 + + :param exc: 未知异常对象 + :return: 包含错误关联标识的安全响应 + """ + error_id = uuid4().hex + logger.bind(error_id=error_id).opt(exception=exc).error('请求处理失败,错误标识:{}', error_id) + return ResponseUtil.error( + msg='服务暂时不可用,请稍后重试', + data={'errorId': error_id}, + headers={'X-Error-Id': error_id}, + ) diff --git a/ruoyi-fastapi-backend/utils/security_util.py b/ruoyi-fastapi-backend/utils/security_util.py new file mode 100644 index 000000000..d2b9e670e --- /dev/null +++ b/ruoyi-fastapi-backend/utils/security_util.py @@ -0,0 +1,108 @@ +import re +from urllib.parse import urlsplit + +import nh3 + + +class SecurityUtil: + """ + 安全工具类 + """ + + # 仅允许文档型富文本;仅保留文本样式,不接受表单或可执行内容,视频嵌入强制使用沙箱。 + HTML_TAGS = { + 'p', + 'br', + 'div', + 'span', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'strong', + 'b', + 'em', + 'i', + 'u', + 's', + 'strike', + 'sub', + 'sup', + 'blockquote', + 'pre', + 'code', + 'ul', + 'ol', + 'li', + 'hr', + 'table', + 'thead', + 'tbody', + 'tfoot', + 'tr', + 'th', + 'td', + 'a', + 'img', + 'iframe', + } + HTML_ATTRIBUTES = { + '*': {'class', 'title', 'style'}, + 'a': {'href'}, + 'img': {'src', 'alt', 'width', 'height'}, + 'iframe': {'src', 'width', 'height', 'frameborder', 'allowfullscreen'}, + 'th': {'colspan', 'rowspan'}, + 'td': {'colspan', 'rowspan'}, + 'ol': {'start'}, + } + + @classmethod + def _filter_html_attribute(cls, tag: str, attribute: str, value: str) -> str | None: + """ + 限制富文本样式表达式和嵌入视频地址。 + + :param tag: 标签名称 + :param attribute: 属性名称 + :param value: 已解析的属性值 + :return: 允许的属性值,不安全时返回 None + """ + if attribute == 'style': + # 不允许 CSS 转义、动态表达式及外部资源,仅保留静态文本格式。 + normalized = re.sub(r'/\*.*?\*/', '', value, flags=re.DOTALL) + if re.search(r'(?:expression|url|var)\s*\(|[\\@]', normalized, re.IGNORECASE): + return None + if tag == 'iframe' and attribute == 'src': + try: + parsed = urlsplit(value) + if parsed.scheme.lower() not in {'http', 'https'} or not parsed.netloc: + return None + except ValueError: + return None + return value + + @classmethod + def sanitize_html(cls, content: str | bytes | None) -> str | None: + """ + 按富文本白名单清洗 HTML 内容。 + + :param content: 原始 HTML 内容,兼容数据库二进制内容 + :return: 清洗后的 HTML,未提供正文时返回 None + """ + if content is None: + return None + if isinstance(content, bytes): + content = content.decode('utf-8', errors='replace') + return nh3.clean( + content, + tags=cls.HTML_TAGS, + attributes=cls.HTML_ATTRIBUTES, + attribute_filter=cls._filter_html_attribute, + set_tag_attribute_values={ + 'iframe': {'sandbox': 'allow-scripts allow-presentation', 'referrerpolicy': 'no-referrer'}, + }, + filter_style_properties={'color', 'background-color', 'font-size', 'font-family', 'text-align'}, + url_schemes={'http', 'https', 'mailto'}, + clean_content_tags={'script', 'style', 'object', 'embed', 'svg', 'math'}, + ) diff --git a/ruoyi-fastapi-backend/utils/server_util.py b/ruoyi-fastapi-backend/utils/server_util.py index 5b2c04473..cf99bc71f 100644 --- a/ruoyi-fastapi-backend/utils/server_util.py +++ b/ruoyi-fastapi-backend/utils/server_util.py @@ -38,11 +38,11 @@ class APIDocsUtil: DEFAULT_SWAGGER_FAVICON_URL = 'https://fastapi.tiangolo.com/img/favicon.png' @classmethod - def proxy_openapi_url(cls) -> str: + def proxy_openapi_url(cls) -> str | None: """ 代理OpenAPI文档URL """ - return cls._PROXY_OPENAPI_URL if not AppConfig.app_disable_swagger and not AppConfig.app_disable_redoc else None + return cls._PROXY_OPENAPI_URL if not (AppConfig.app_disable_swagger and AppConfig.app_disable_redoc) else None @classmethod def docs_url(cls) -> str: @@ -154,7 +154,8 @@ async def custom_swagger_ui_redirect(request: Request) -> HTMLResponse: return await cls._custom_swagger_ui_redirect(app, swagger_favicon_url) # 注册路由 - app.add_route(cls._OPENAPI_URL, custom_openapi, include_in_schema=False) + if not (AppConfig.app_disable_swagger and AppConfig.app_disable_redoc): + app.add_route(cls._OPENAPI_URL, custom_openapi, include_in_schema=False) cls._register_docs_routes(app, custom_swagger, custom_swagger_ui_redirect, custom_redoc) @classmethod diff --git a/ruoyi-fastapi-frontend/react/mobile/package.json b/ruoyi-fastapi-frontend/react/mobile/package.json index 61a997032..c3cda335d 100644 --- a/ruoyi-fastapi-frontend/react/mobile/package.json +++ b/ruoyi-fastapi-frontend/react/mobile/package.json @@ -32,7 +32,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "pnpm test:unit && pnpm test:contract", - "test:unit": "node --test tests/avatar-crop.test.mjs tests/native-styles.test.mjs tests/route-params.test.mjs tests/transport-forge.test.mjs tests/timezone-data.test.mjs", + "test:unit": "node --test tests/avatar-crop.test.mjs tests/native-styles.test.mjs tests/route-params.test.mjs tests/transport-forge.test.mjs tests/timezone-data.test.mjs && pnpm test:session", "test:contract": "pnpm test:time", "test:time": "node --test tests/time.test.mjs tests/time-polyfill.test.mjs", "dev:rn": "taro build --type rn --watch", @@ -41,7 +41,8 @@ "build:rn:ios": "node scripts/build-native.cjs ios", "build:app": "pnpm build:rn:android", "generate:icons": "node scripts/generate-icons.cjs", - "check:platforms": "node scripts/check-platforms.cjs" + "check:platforms": "node scripts/check-platforms.cjs", + "test:session": "node --experimental-vm-modules --test tests/logout.test.mjs" }, "dependencies": { "@babel/runtime": "7.29.10", diff --git a/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx b/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx index 273fc47a1..8c83d7143 100644 --- a/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx +++ b/ruoyi-fastapi-frontend/react/mobile/src/pages/common/webview/index.jsx @@ -1,13 +1,14 @@ import React, { useState } from 'react' import Taro, { useLoad, useDidShow } from '@tarojs/taro' import { decodeRouteParam } from '@/utils/routeParams' +import { resolveWebviewUrl } from '@/utils/webview' import { WebView, View, Text } from '@tarojs/components' export default function Browser() { const [params, setParams] = useState({}) useLoad((options) => { setParams({ title: decodeRouteParam(options.title), - url: decodeRouteParam(options.url), + url: resolveWebviewUrl(options.url), }) }) useDidShow(() => { @@ -16,7 +17,7 @@ export default function Browser() { React.useEffect(() => { if (params.title) Taro.setNavigationBarTitle({ title: params.title }) }, [params.title]) - return /^https?:\/\//i.test(params.url || '') ? ( + return params.url ? ( ) : ( diff --git a/ruoyi-fastapi-frontend/react/mobile/src/store/modules/user.js b/ruoyi-fastapi-frontend/react/mobile/src/store/modules/user.js index 6a1052e10..2c257441c 100644 --- a/ruoyi-fastapi-frontend/react/mobile/src/store/modules/user.js +++ b/ruoyi-fastapi-frontend/react/mobile/src/store/modules/user.js @@ -97,8 +97,14 @@ export const useUserStore = create((set, get) => ({ timeZone: 'auto', }) }, + // 退出系统:远端撤销失败也必须清理本地会话,保证可以重新登录。 async logOut() { - await logout() - get().clear() + try { + await logout() + } catch { + // 远端异常由请求层提示,本地退出不依赖远端撤销结果。 + } finally { + get().clear() + } }, })) diff --git a/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js b/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js new file mode 100644 index 000000000..2e2419a6e --- /dev/null +++ b/ruoyi-fastapi-frontend/react/mobile/src/utils/webview.js @@ -0,0 +1,22 @@ +/** + * 解析 WebView 路由地址,保留 HTTP(S) 外部网页能力。 + * 仅对整体编码的 URL 解码一次,避免改写已解码地址中的嵌套查询参数。 + * @param {string} value 路由传入的网页地址 + * @returns {string} 合法的 HTTP(S) 地址,无效时返回空字符串 + */ +export function resolveWebviewUrl(value) { + if (typeof value !== 'string') return '' + let url = value.trim() + if (/^https?%3a%2f%2f/i.test(url)) { + try { + url = decodeURIComponent(url) + } catch { + return '' + } + } + // 各移动平台的 URL API 支持不同,使用保守的协议与主机检查。 + if (!/^https?:\/\/[^/?#\s]+(?:[/?#]|$)/i.test(url) || /[\u0000-\u0020\u007f\\]/.test(url)) { + return '' + } + return url +} diff --git a/ruoyi-fastapi-frontend/react/mobile/tests/logout.test.mjs b/ruoyi-fastapi-frontend/react/mobile/tests/logout.test.mjs new file mode 100644 index 000000000..ef6f7a2a9 --- /dev/null +++ b/ruoyi-fastapi-frontend/react/mobile/tests/logout.test.mjs @@ -0,0 +1,3 @@ +import { runLogoutTests } from '../../../../ruoyi-fastapi-test/frontend/logout-behavior.mjs' + +runLogoutTests(new URL('../', import.meta.url), 'react-mobile') diff --git a/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs b/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs index 8c99b3aa5..0899578f6 100644 --- a/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs +++ b/ruoyi-fastapi-frontend/react/mobile/tests/route-params.test.mjs @@ -17,3 +17,21 @@ test('missing and malformed query values do not crash the page', () => { assert.equal(decodeRouteParam('说明 100%'), '说明 100%') assert.equal(decodeRouteParam('%E4%B8'), '%E4%B8') }) + +test('webview keeps nested query encoding and rejects non-HTTP protocols', async () => { + const { resolveWebviewUrl } = await import('../src/utils/webview.js') + const url = + 'https://example.com/中文?q=100%25&next=https%3A%2F%2Fother.example%2F%3Fa%3D1%26b%3D2' + assert.equal(resolveWebviewUrl(url), url) + assert.equal(resolveWebviewUrl(encodeURIComponent(url)), url) + for (const invalid of [ + 'javascript:alert(1)', + 'data:text/html,x', + 'file:///private', + '//example.com', + 'https://', + '%E4%B8', + ]) { + assert.equal(resolveWebviewUrl(invalid), '') + } +}) diff --git a/ruoyi-fastapi-frontend/react/web/package.json b/ruoyi-fastapi-frontend/react/web/package.json index 1ae21b348..05ba52a8d 100644 --- a/ruoyi-fastapi-frontend/react/web/package.json +++ b/ruoyi-fastapi-frontend/react/web/package.json @@ -14,14 +14,16 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "pnpm test:unit && pnpm test:contract", - "test:unit": "pnpm test:auth-center && pnpm test:plugin && pnpm test:job", + "test:unit": "pnpm test:auth-center && pnpm test:plugin && pnpm test:job && pnpm test:security && pnpm test:session", + "test:security": "node --test tests/security/*.test.cjs", "test:contract": "pnpm test:time", "test:job": "node --test tests/job/*.test.js", "test:plugin": "node --test tests/plugins/*.test.js && pnpm test:components tests/react/pluginFrame.test.jsx tests/react/plugin-status.test.jsx", "test:auth-center": "node --test tests/auth-center/*.test.js && pnpm test:components tests/react/useInteraction.test.jsx", "test:time": "node --test tests/time/time.test.js", "test:components": "vitest run --config vitest.config.js", - "preview": "vite preview" + "preview": "vite preview", + "test:session": "node --experimental-vm-modules --test tests/security/logout.test.mjs" }, "dependencies": { "@ant-design/icons": "6.3.4", diff --git a/ruoyi-fastapi-frontend/react/web/src/layout/components/HeaderNotice/DetailView.jsx b/ruoyi-fastapi-frontend/react/web/src/layout/components/HeaderNotice/DetailView.jsx index e44b71351..fbc89989c 100644 --- a/ruoyi-fastapi-frontend/react/web/src/layout/components/HeaderNotice/DetailView.jsx +++ b/ruoyi-fastapi-frontend/react/web/src/layout/components/HeaderNotice/DetailView.jsx @@ -2,7 +2,7 @@ import './style.css' import { UserOutlined, ClockCircleOutlined } from '@ant-design/icons' import { useEffect, useState } from 'react' import { Drawer, Empty, Spin } from 'antd' -import DOMPurify from 'dompurify' +import { sanitizeNoticeHtml } from '@/utils/noticeHtml' import { getNotice } from '@/api/system/notice' import { parseTime } from '@/utils/ruoyi' export default function NoticeDetailView({ noticeId, onClose }) { @@ -69,7 +69,7 @@ export function NoticeArticle({ detail }) { {detail.noticeContent ? (
) : ( diff --git a/ruoyi-fastapi-frontend/react/web/src/store/modules/user.js b/ruoyi-fastapi-frontend/react/web/src/store/modules/user.js index 693c2fc7d..878cab486 100644 --- a/ruoyi-fastapi-frontend/react/web/src/store/modules/user.js +++ b/ruoyi-fastapi-frontend/react/web/src/store/modules/user.js @@ -59,10 +59,15 @@ const useUserStore = create((set, get) => ({ }) return result }, - // 服务端退出成功后清理本地登录状态。 + // 退出系统:远端撤销失败也必须清理本地会话,保证可以重新登录。 async logOut() { - await logout() - get().clear() + try { + await logout() + } catch { + // 远端异常由请求层提示,本地退出不依赖远端撤销结果。 + } finally { + get().clear() + } }, // 清除令牌和授权状态,将显示时区恢复为跟随设备并解除锁屏。 clear() { diff --git a/ruoyi-fastapi-frontend/react/web/src/utils/noticeHtml.js b/ruoyi-fastapi-frontend/react/web/src/utils/noticeHtml.js new file mode 100644 index 000000000..8c004ac34 --- /dev/null +++ b/ruoyi-fastapi-frontend/react/web/src/utils/noticeHtml.js @@ -0,0 +1,71 @@ +import createDOMPurify from 'dompurify' + +const purifier = createDOMPurify(window) +purifier.addHook('uponSanitizeAttribute', (node, data) => { + if ( + node.tagName === 'IFRAME' && + data.attrName === 'src' && + !/^https?:\/\/[^/?#\s]+/i.test(data.attrValue) + ) { + data.keepAttr = false + } + if (data.attrName === 'href' || data.attrName === 'src') { + // 显式限制包括图片 data URI 在内的所有链接协议。 + const value = data.attrValue.replace(/[\u0000-\u0020\u007f-\u009f]/g, '') + const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i) + if (scheme && !['http', 'https', 'mailto'].includes(scheme[1].toLowerCase())) { + data.keepAttr = false + } + } +}) + +purifier.addHook('afterSanitizeAttributes', (node) => { + if (node.tagName === 'IFRAME') { + // 保留 Quill 视频能力,禁止嵌入页面访问父页面及同源存储。 + node.setAttribute('sandbox', 'allow-scripts allow-presentation') + node.setAttribute('referrerpolicy', 'no-referrer') + } + if (!node.hasAttribute('style')) return + // 保留编辑器使用的文本格式,剔除定位、背景资源等非正文样式。 + const properties = ['color', 'background-color', 'font-size', 'font-family', 'text-align'] + const styles = properties.map((property) => [property, node.style.getPropertyValue(property)]) + node.removeAttribute('style') + for (const [property, value] of styles) { + if (value) node.style.setProperty(property, value) + } +}) + +/** + * 清洗公告正文,与后端 SecurityUtil.sanitize_html 的富文本白名单保持一致。 + * @param {string} content 原始公告正文 + * @returns {string} 可安全渲染的公告正文 + */ +export function sanitizeNoticeHtml(content) { + return purifier.sanitize(content || '', { + ALLOWED_TAGS: + 'p br div span h1 h2 h3 h4 h5 h6 strong b em i u s strike sub sup blockquote pre code ul ol li hr table thead tbody tfoot tr th td a img iframe'.split( + ' ' + ), + ALLOWED_ATTR: [ + 'class', + 'title', + 'style', + 'href', + 'src', + 'alt', + 'width', + 'height', + 'colspan', + 'rowspan', + 'start', + 'rel', + 'sandbox', + 'referrerpolicy', + 'frameborder', + 'allowfullscreen', + ], + ALLOW_DATA_ATTR: false, + ALLOW_ARIA_ATTR: false, + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, + }) +} diff --git a/ruoyi-fastapi-frontend/react/web/tests/security/logout.test.mjs b/ruoyi-fastapi-frontend/react/web/tests/security/logout.test.mjs new file mode 100644 index 000000000..643d17261 --- /dev/null +++ b/ruoyi-fastapi-frontend/react/web/tests/security/logout.test.mjs @@ -0,0 +1,3 @@ +import { runLogoutTests } from '../../../../../ruoyi-fastapi-test/frontend/logout-behavior.mjs' + +runLogoutTests(new URL('../../', import.meta.url), 'react-web') diff --git a/ruoyi-fastapi-frontend/react/web/tests/security/notice-html.test.cjs b/ruoyi-fastapi-frontend/react/web/tests/security/notice-html.test.cjs new file mode 100644 index 000000000..bdff8d39a --- /dev/null +++ b/ruoyi-fastapi-frontend/react/web/tests/security/notice-html.test.cjs @@ -0,0 +1,44 @@ +const assert = require('node:assert/strict') +const { readFileSync } = require('node:fs') +const { resolve } = require('node:path') +const { test } = require('node:test') +const createDOMPurify = require('dompurify') +const { JSDOM } = require('jsdom') + +for (const framework of ['vue2', 'vue3', 'react']) { + test(`${framework} notice HTML removes active content and keeps rich text`, () => { + const source = readFileSync( + resolve(__dirname, `../../../../${framework}/web/src/utils/noticeHtml.js`), + 'utf8' + ) + .replace("import createDOMPurify from 'dompurify'", '') + .replace('export function', 'function') + const window = new JSDOM('').window + const sanitize = new Function( + 'createDOMPurify', + 'window', + `${source}; return sanitizeNoticeHtml` + )(createDOMPurify, window) + const clean = sanitize( + '

标题

重点

x' + ) + assert.match(clean, /重点<\/strong>/) + assert.match(clean, /src="\/safe.png"/) + assert.doesNotMatch(clean, /onerror|onload|正文

' + ) + assert.match(styled, /ql-align-center/) + assert.match(styled, /color: red/) + assert.match(styled, /background-color: yellow/) + assert.doesNotMatch(styled, /position|background-image/) + const video = sanitize( + '' + ) + assert.match(video, /src="https:\/\/video.example\/embed\/1"/) + assert.match(video, /sandbox="allow-scripts allow-presentation"/) + assert.doesNotMatch(video, /srcdoc|allow-same-origin/) + window.close() + }) +} diff --git a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue index 1e66ffa7c..cb5352c2c 100644 --- a/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue +++ b/ruoyi-fastapi-frontend/vue2/mobile/src/pages/common/webview/index.vue @@ -8,6 +8,7 @@