From e5d7700b36b34da8c424f832c82e3f3a2e3367de Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 23 Feb 2026 17:48:32 +0800 Subject: [PATCH] harden released pages and archive openspec change --- .env.example | 19 ++ .../released-pages-hardening-gates.yml | 61 +++++ README.md | 13 ++ .../asset_readiness_manifest.json | 22 ++ .../bug_revalidation_records.json | 3 + .../exception_registry.json | 123 +++++++++++ .../known_bug_baseline.json | 21 ++ .../manual_acceptance_records.json | 3 + .../quality_gate_policy.json | 6 + .../route_contracts.json | 208 ++++++++++++++++++ .../route_scope_matrix.json | 22 ++ .../style_inventory.json | 4 + .../baseline_api_payload_contracts.json | 46 ++++ .../baseline_drawer_contract_validation.json | 4 + .../baseline_drawer_visibility.json | 201 +++++++++++++++++ .../baseline_route_query_contracts.json | 46 ++++ frontend/src/job-query/main.js | 101 ++++++++- .../.openspec.yaml | 2 + .../design.md | 90 ++++++++ .../proposal.md | 37 ++++ .../spec.md | 81 +++++++ .../tasks.md | 34 +++ .../spec.md | 85 +++++++ src/mes_dashboard/app.py | 50 ++++- src/mes_dashboard/config/settings.py | 18 +- src/mes_dashboard/core/rate_limit.py | 64 +++++- src/mes_dashboard/core/redis_client.py | 48 +++- src/mes_dashboard/core/request_validation.py | 64 ++++++ src/mes_dashboard/routes/hold_routes.py | 19 +- src/mes_dashboard/routes/job_query_routes.py | 21 +- src/mes_dashboard/routes/query_tool_routes.py | 103 +++++---- src/mes_dashboard/routes/resource_routes.py | 40 +++- src/mes_dashboard/routes/wip_routes.py | 5 +- src/mes_dashboard/services/page_registry.py | 13 +- .../services/query_tool_service.py | 46 ++-- .../services/resource_service.py | 17 ++ tests/test_app_factory.py | 28 +++ tests/test_hold_routes.py | 36 ++- tests/test_job_query_frontend_safety.py | 22 ++ tests/test_job_query_routes.py | 69 +++++- tests/test_page_registry.py | 16 ++ tests/test_query_tool_routes.py | 122 ++++++++-- tests/test_rate_limit_identity.py | 62 ++++++ tests/test_redis_client.py | 42 +++- tests/test_resource_routes.py | 74 +++++++ tests/test_runtime_hardening.py | 30 +++ tests/test_wip_routes.py | 26 +++ 47 files changed, 2126 insertions(+), 141 deletions(-) create mode 100644 .github/workflows/released-pages-hardening-gates.yml create mode 100644 docs/migration/full-modernization-architecture-blueprint/asset_readiness_manifest.json create mode 100644 docs/migration/full-modernization-architecture-blueprint/bug_revalidation_records.json create mode 100644 docs/migration/full-modernization-architecture-blueprint/exception_registry.json create mode 100644 docs/migration/full-modernization-architecture-blueprint/known_bug_baseline.json create mode 100644 docs/migration/full-modernization-architecture-blueprint/manual_acceptance_records.json create mode 100644 docs/migration/full-modernization-architecture-blueprint/quality_gate_policy.json create mode 100644 docs/migration/full-modernization-architecture-blueprint/route_contracts.json create mode 100644 docs/migration/full-modernization-architecture-blueprint/route_scope_matrix.json create mode 100644 docs/migration/full-modernization-architecture-blueprint/style_inventory.json create mode 100644 docs/migration/portal-no-iframe/baseline_api_payload_contracts.json create mode 100644 docs/migration/portal-no-iframe/baseline_drawer_contract_validation.json create mode 100644 docs/migration/portal-no-iframe/baseline_drawer_visibility.json create mode 100644 docs/migration/portal-no-iframe/baseline_route_query_contracts.json create mode 100644 openspec/changes/archive/2026-02-23-released-pages-production-hardening/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-23-released-pages-production-hardening/design.md create mode 100644 openspec/changes/archive/2026-02-23-released-pages-production-hardening/proposal.md create mode 100644 openspec/changes/archive/2026-02-23-released-pages-production-hardening/specs/released-pages-production-hardening/spec.md create mode 100644 openspec/changes/archive/2026-02-23-released-pages-production-hardening/tasks.md create mode 100644 openspec/specs/released-pages-production-hardening/spec.md create mode 100644 src/mes_dashboard/core/request_validation.py create mode 100644 tests/test_job_query_frontend_safety.py create mode 100644 tests/test_rate_limit_identity.py diff --git a/.env.example b/.env.example index 38c31bc..94f7cac 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,7 @@ DB_CALL_TIMEOUT_MS=55000 # Must stay below worker timeout # Flask Configuration # ============================================================ # Environment mode: development | production | testing +# If omitted, runtime defaults to production (fail-safe) FLASK_ENV=development # Debug mode: 0 for production, 1 for development @@ -43,6 +44,24 @@ SECRET_KEY=your-secret-key-change-in-production # Session timeout in seconds (default: 28800 = 8 hours) SESSION_LIFETIME=28800 +# JSON request payload upper bound in bytes (default: 262144 = 256KB) +MAX_JSON_BODY_BYTES=262144 + +# Route input-budget guardrails +QUERY_TOOL_MAX_CONTAINER_IDS=200 +RESOURCE_DETAIL_DEFAULT_LIMIT=500 +RESOURCE_DETAIL_MAX_LIMIT=500 + +# Trust boundary for forwarded headers (safe default: false) +# Direct-exposure deployment (no reverse proxy): keep this false +TRUST_PROXY_HEADERS=false +# Required when TRUST_PROXY_HEADERS=true. Supports comma-separated IP/CIDR entries. +# Example: TRUSTED_PROXY_IPS=127.0.0.1,10.0.0.0/24 +TRUSTED_PROXY_IPS= + +# CSP opt-in compatibility flag (default false = safer) +CSP_ALLOW_UNSAFE_EVAL=false + # ============================================================ # Authentication Configuration # ============================================================ diff --git a/.github/workflows/released-pages-hardening-gates.yml b/.github/workflows/released-pages-hardening-gates.yml new file mode 100644 index 0000000..2108050 --- /dev/null +++ b/.github/workflows/released-pages-hardening-gates.yml @@ -0,0 +1,61 @@ +name: released-pages-hardening-gates + +on: + pull_request: + paths: + - "src/mes_dashboard/**" + - "frontend/src/job-query/**" + - "tests/test_query_tool_routes.py" + - "tests/test_job_query_routes.py" + - "tests/test_resource_routes.py" + - "tests/test_rate_limit_identity.py" + - "tests/test_page_registry.py" + - "tests/test_redis_client.py" + - "tests/test_runtime_hardening.py" + - "tests/test_hold_routes.py" + - "tests/test_wip_routes.py" + - "tests/test_job_query_frontend_safety.py" + - ".github/workflows/released-pages-hardening-gates.yml" + push: + branches: [ main ] + paths: + - "src/mes_dashboard/**" + - "frontend/src/job-query/**" + - "tests/test_query_tool_routes.py" + - "tests/test_job_query_routes.py" + - "tests/test_resource_routes.py" + - "tests/test_rate_limit_identity.py" + - "tests/test_page_registry.py" + - "tests/test_redis_client.py" + - "tests/test_runtime_hardening.py" + - "tests/test_hold_routes.py" + - "tests/test_wip_routes.py" + - "tests/test_job_query_frontend_safety.py" + - ".github/workflows/released-pages-hardening-gates.yml" + +jobs: + released-pages-hardening: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e . pytest + - name: Run released-pages hardening regression suite + run: | + python -m pytest -q \ + tests/test_query_tool_routes.py \ + tests/test_job_query_routes.py \ + tests/test_resource_routes.py \ + tests/test_rate_limit_identity.py \ + tests/test_page_registry.py \ + tests/test_redis_client.py \ + tests/test_runtime_hardening.py \ + tests/test_hold_routes.py \ + tests/test_wip_routes.py \ + tests/test_job_query_frontend_safety.py \ + -k "not TestJobQueryPage and not TestHoldDetailPageRoute and not TestPageRoutes" diff --git a/README.md b/README.md index 5a03745..d9fe245 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,19 @@ DB_PASSWORD=your_password # Flask 設定 FLASK_ENV=production # production | development SECRET_KEY=your-secret-key # 生產環境請更換 +MAX_JSON_BODY_BYTES=262144 # JSON 請求大小上限(bytes) + +# 輸入預算保護(Released 高成本 API) +QUERY_TOOL_MAX_CONTAINER_IDS=200 +RESOURCE_DETAIL_DEFAULT_LIMIT=500 +RESOURCE_DETAIL_MAX_LIMIT=500 + +# 反向代理信任邊界(無反向代理時務必維持 false) +TRUST_PROXY_HEADERS=false +TRUSTED_PROXY_IPS=127.0.0.1 + +# CSP 相容開關(預設 false;僅在必要時啟用) +CSP_ALLOW_UNSAFE_EVAL=false # Gunicorn 設定 GUNICORN_BIND=0.0.0.0:8080 # 服務監聽位址 diff --git a/docs/migration/full-modernization-architecture-blueprint/asset_readiness_manifest.json b/docs/migration/full-modernization-architecture-blueprint/asset_readiness_manifest.json new file mode 100644 index 0000000..9f2c9ab --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/asset_readiness_manifest.json @@ -0,0 +1,22 @@ +{ + "in_scope_required_assets": { + "/wip-overview": ["wip-overview.js"], + "/wip-detail": ["wip-detail.js"], + "/hold-overview": ["hold-overview.js"], + "/hold-detail": ["hold-detail.js"], + "/hold-history": ["hold-history.js"], + "/reject-history": ["reject-history.js"], + "/resource": ["resource-status.js"], + "/resource-history": ["resource-history.js"], + "/qc-gate": ["qc-gate.js"], + "/job-query": ["job-query.js"], + "/tmtt-defect": ["tmtt-defect.js"], + "/admin/pages": ["admin-pages.js"], + "/admin/performance": ["admin-performance.js"], + "/tables": ["tables.js"], + "/excel-query": ["excel-query.js"], + "/query-tool": ["query-tool.js"], + "/mid-section-defect": ["mid-section-defect.js"] + }, + "deferred_routes": [] +} diff --git a/docs/migration/full-modernization-architecture-blueprint/bug_revalidation_records.json b/docs/migration/full-modernization-architecture-blueprint/bug_revalidation_records.json new file mode 100644 index 0000000..aa38702 --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/bug_revalidation_records.json @@ -0,0 +1,3 @@ +{ + "records": [] +} diff --git a/docs/migration/full-modernization-architecture-blueprint/exception_registry.json b/docs/migration/full-modernization-architecture-blueprint/exception_registry.json new file mode 100644 index 0000000..47ce49a --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/exception_registry.json @@ -0,0 +1,123 @@ +{ + "entries": [ + { + "id": "style-legacy-wip-overview", + "scope": "/wip-overview", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-wip-detail", + "scope": "/wip-detail", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-hold-overview", + "scope": "/hold-overview", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-hold-detail", + "scope": "/hold-detail", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-hold-history", + "scope": "/hold-history", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-reject-history", + "scope": "/reject-history", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-resource", + "scope": "/resource", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-resource-history", + "scope": "/resource-history", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-qc-gate", + "scope": "/qc-gate", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-job-query", + "scope": "/job-query", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-tmtt-defect", + "scope": "/tmtt-defect", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-admin-pages", + "scope": "/admin/pages", + "owner": "frontend-platform-admin", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-admin-performance", + "scope": "/admin/performance", + "owner": "frontend-platform-admin", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-tables", + "scope": "/tables", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-excel-query", + "scope": "/excel-query", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-query-tool", + "scope": "/query-tool", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + }, + { + "id": "style-legacy-mid-section-defect", + "scope": "/mid-section-defect", + "owner": "frontend-mes-reporting", + "milestone": "full-modernization-phase2", + "reason": "Legacy styles pending full token and scope migration" + } + ] +} diff --git a/docs/migration/full-modernization-architecture-blueprint/known_bug_baseline.json b/docs/migration/full-modernization-architecture-blueprint/known_bug_baseline.json new file mode 100644 index 0000000..ab9ae30 --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/known_bug_baseline.json @@ -0,0 +1,21 @@ +{ + "routes": { + "/wip-overview": { "known_issues": [] }, + "/wip-detail": { "known_issues": [] }, + "/hold-overview": { "known_issues": [] }, + "/hold-detail": { "known_issues": [] }, + "/hold-history": { "known_issues": [] }, + "/reject-history": { "known_issues": [] }, + "/resource": { "known_issues": [] }, + "/resource-history": { "known_issues": [] }, + "/qc-gate": { "known_issues": [] }, + "/job-query": { "known_issues": [] }, + "/tmtt-defect": { "known_issues": [] }, + "/tables": { "known_issues": [] }, + "/excel-query": { "known_issues": [] }, + "/query-tool": { "known_issues": [] }, + "/mid-section-defect": { "known_issues": [] }, + "/admin/pages": { "known_issues": [] }, + "/admin/performance": { "known_issues": [] } + } +} diff --git a/docs/migration/full-modernization-architecture-blueprint/manual_acceptance_records.json b/docs/migration/full-modernization-architecture-blueprint/manual_acceptance_records.json new file mode 100644 index 0000000..aa38702 --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/manual_acceptance_records.json @@ -0,0 +1,3 @@ +{ + "records": [] +} diff --git a/docs/migration/full-modernization-architecture-blueprint/quality_gate_policy.json b/docs/migration/full-modernization-architecture-blueprint/quality_gate_policy.json new file mode 100644 index 0000000..2e2db19 --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/quality_gate_policy.json @@ -0,0 +1,6 @@ +{ + "severity_mode": { + "current": "block" + }, + "deferred_routes_excluded": [] +} diff --git a/docs/migration/full-modernization-architecture-blueprint/route_contracts.json b/docs/migration/full-modernization-architecture-blueprint/route_contracts.json new file mode 100644 index 0000000..9bd63cf --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/route_contracts.json @@ -0,0 +1,208 @@ +{ + "routes": [ + { + "route": "/wip-overview", + "route_id": "wip-overview", + "title": "WIP Overview", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/wip-overview", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/wip-detail", + "route_id": "wip-detail", + "title": "WIP Detail", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/wip-detail", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/hold-overview", + "route_id": "hold-overview", + "title": "Hold Overview", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/hold-overview", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/hold-detail", + "route_id": "hold-detail", + "title": "Hold Detail", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/hold-detail", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/hold-history", + "route_id": "hold-history", + "title": "Hold History", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/hold-history", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/reject-history", + "route_id": "reject-history", + "title": "Reject History", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/reject-history", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/resource", + "route_id": "resource", + "title": "Resource", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/resource", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/resource-history", + "route_id": "resource-history", + "title": "Resource History", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/resource-history", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/qc-gate", + "route_id": "qc-gate", + "title": "QC Gate", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/qc-gate", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/job-query", + "route_id": "job-query", + "title": "Job Query", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/job-query", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/tmtt-defect", + "route_id": "tmtt-defect", + "title": "TMTT Defect", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/tmtt-defect", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/admin/pages", + "route_id": "admin-pages", + "title": "Admin Pages", + "scope": "in-scope", + "render_mode": "external", + "owner": "frontend-platform-admin", + "visibility_policy": "admin_only", + "canonical_shell_path": "/portal-shell/admin/pages", + "rollback_strategy": "external_route_reversion", + "compatibility_policy": "external_target_redirect" + }, + { + "route": "/admin/performance", + "route_id": "admin-performance", + "title": "Admin Performance", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-platform-admin", + "visibility_policy": "admin_only", + "canonical_shell_path": "/portal-shell/admin/performance", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/tables", + "route_id": "tables", + "title": "Tables", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/tables", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/excel-query", + "route_id": "excel-query", + "title": "Excel Query", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/excel-query", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/query-tool", + "route_id": "query-tool", + "title": "Query Tool", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/query-tool", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + }, + { + "route": "/mid-section-defect", + "route_id": "mid-section-defect", + "title": "Mid Section Defect", + "scope": "in-scope", + "render_mode": "native", + "owner": "frontend-mes-reporting", + "visibility_policy": "released_or_admin", + "canonical_shell_path": "/portal-shell/mid-section-defect", + "rollback_strategy": "fallback_to_legacy_route", + "compatibility_policy": "redirect_to_shell_when_spa_enabled" + } + ] +} diff --git a/docs/migration/full-modernization-architecture-blueprint/route_scope_matrix.json b/docs/migration/full-modernization-architecture-blueprint/route_scope_matrix.json new file mode 100644 index 0000000..bcb00b2 --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/route_scope_matrix.json @@ -0,0 +1,22 @@ +{ + "in_scope": [ + { "route": "/wip-overview", "category": "report" }, + { "route": "/wip-detail", "category": "report" }, + { "route": "/hold-overview", "category": "report" }, + { "route": "/hold-detail", "category": "report" }, + { "route": "/hold-history", "category": "report" }, + { "route": "/reject-history", "category": "report" }, + { "route": "/resource", "category": "report" }, + { "route": "/resource-history", "category": "report" }, + { "route": "/qc-gate", "category": "report" }, + { "route": "/job-query", "category": "report" }, + { "route": "/tmtt-defect", "category": "report" }, + { "route": "/tables", "category": "report" }, + { "route": "/excel-query", "category": "report" }, + { "route": "/query-tool", "category": "report" }, + { "route": "/mid-section-defect", "category": "report" }, + { "route": "/admin/pages", "category": "admin" }, + { "route": "/admin/performance", "category": "admin" } + ], + "deferred": [] +} diff --git a/docs/migration/full-modernization-architecture-blueprint/style_inventory.json b/docs/migration/full-modernization-architecture-blueprint/style_inventory.json new file mode 100644 index 0000000..f3d18ec --- /dev/null +++ b/docs/migration/full-modernization-architecture-blueprint/style_inventory.json @@ -0,0 +1,4 @@ +{ + "routes": {}, + "notes": "Baseline placeholder inventory" +} diff --git a/docs/migration/portal-no-iframe/baseline_api_payload_contracts.json b/docs/migration/portal-no-iframe/baseline_api_payload_contracts.json new file mode 100644 index 0000000..42a4774 --- /dev/null +++ b/docs/migration/portal-no-iframe/baseline_api_payload_contracts.json @@ -0,0 +1,46 @@ +{ + "source": "current frontend API consumption contracts", + "apis": { + "/api/wip/overview/summary": { + "required_keys": [ + "dataUpdateDate", + "runLots", + "queueLots", + "holdLots" + ], + "notes": "summary header and cards depend on these fields" + }, + "/api/wip/overview/matrix": { + "required_keys": [ + "workcenters", + "packages", + "matrix", + "workcenter_totals" + ], + "notes": "matrix table rendering contract" + }, + "/api/wip/hold-detail/summary": { + "required_keys": [ + "workcenterCount", + "packageCount", + "lotCount" + ], + "notes": "hold detail summary cards contract" + }, + "/api/resource/history/summary": { + "required_keys": [ + "kpi", + "trend", + "heatmap", + "workcenter_comparison" + ], + "notes": "resource history chart summary contract" + }, + "/api/resource/history/detail": { + "required_keys": [ + "data" + ], + "notes": "detail table contract (plus truncated/max_records metadata when present)" + } + } +} diff --git a/docs/migration/portal-no-iframe/baseline_drawer_contract_validation.json b/docs/migration/portal-no-iframe/baseline_drawer_contract_validation.json new file mode 100644 index 0000000..177e28b --- /dev/null +++ b/docs/migration/portal-no-iframe/baseline_drawer_contract_validation.json @@ -0,0 +1,4 @@ +{ + "source": "data/page_status.json", + "errors": [] +} diff --git a/docs/migration/portal-no-iframe/baseline_drawer_visibility.json b/docs/migration/portal-no-iframe/baseline_drawer_visibility.json new file mode 100644 index 0000000..cf834dc --- /dev/null +++ b/docs/migration/portal-no-iframe/baseline_drawer_visibility.json @@ -0,0 +1,201 @@ +{ + "source": "data/page_status.json", + "admin": [ + { + "id": "reports", + "name": "即時報表", + "order": 1, + "admin_only": false, + "pages": [ + { + "route": "/wip-overview", + "name": "WIP 即時概況", + "status": "released", + "order": 1 + }, + { + "route": "/hold-overview", + "name": "Hold 即時概況", + "status": "released", + "order": 2 + }, + { + "route": "/resource", + "name": "設備即時概況", + "status": "released", + "order": 4 + }, + { + "route": "/qc-gate", + "name": "QC-GATE 狀態", + "status": "released", + "order": 6 + } + ] + }, + { + "id": "drawer-2", + "name": "歷史報表", + "order": 2, + "admin_only": false, + "pages": [ + { + "route": "/hold-history", + "name": "Hold 歷史績效", + "status": "released", + "order": 3 + }, + { + "route": "/reject-history", + "name": "報廢歷史查詢", + "status": "dev", + "order": 4 + }, + { + "route": "/resource-history", + "name": "設備歷史績效", + "status": "released", + "order": 5 + } + ] + }, + { + "id": "drawer", + "name": "查詢工具", + "order": 3, + "admin_only": false, + "pages": [ + { + "route": "/job-query", + "name": "設備維修查詢", + "status": "released", + "order": 1 + }, + { + "route": "/query-tool", + "name": "批次追蹤工具", + "status": "released", + "order": 2 + } + ] + }, + { + "id": "dev-tools", + "name": "開發工具", + "order": 4, + "admin_only": true, + "pages": [ + { + "route": "/tables", + "name": "表格總覽", + "status": "dev", + "order": 1 + }, + { + "route": "/admin/pages", + "name": "頁面管理", + "status": "released", + "order": 1 + }, + { + "route": "/excel-query", + "name": "Excel 批次查詢", + "status": "dev", + "order": 2 + }, + { + "route": "/admin/performance", + "name": "效能監控", + "status": "dev", + "order": 2 + }, + { + "route": "/tmtt-defect", + "name": "TMTT印字腳型不良分析", + "status": "dev", + "order": 5 + }, + { + "route": "/mid-section-defect", + "name": "中段製程不良追溯", + "status": "dev", + "order": 6 + } + ] + } + ], + "non_admin": [ + { + "id": "reports", + "name": "即時報表", + "order": 1, + "admin_only": false, + "pages": [ + { + "route": "/wip-overview", + "name": "WIP 即時概況", + "status": "released", + "order": 1 + }, + { + "route": "/hold-overview", + "name": "Hold 即時概況", + "status": "released", + "order": 2 + }, + { + "route": "/resource", + "name": "設備即時概況", + "status": "released", + "order": 4 + }, + { + "route": "/qc-gate", + "name": "QC-GATE 狀態", + "status": "released", + "order": 6 + } + ] + }, + { + "id": "drawer-2", + "name": "歷史報表", + "order": 2, + "admin_only": false, + "pages": [ + { + "route": "/hold-history", + "name": "Hold 歷史績效", + "status": "released", + "order": 3 + }, + { + "route": "/resource-history", + "name": "設備歷史績效", + "status": "released", + "order": 5 + } + ] + }, + { + "id": "drawer", + "name": "查詢工具", + "order": 3, + "admin_only": false, + "pages": [ + { + "route": "/job-query", + "name": "設備維修查詢", + "status": "released", + "order": 1 + }, + { + "route": "/query-tool", + "name": "批次追蹤工具", + "status": "released", + "order": 2 + } + ] + } + ] +} diff --git a/docs/migration/portal-no-iframe/baseline_route_query_contracts.json b/docs/migration/portal-no-iframe/baseline_route_query_contracts.json new file mode 100644 index 0000000..220b0ec --- /dev/null +++ b/docs/migration/portal-no-iframe/baseline_route_query_contracts.json @@ -0,0 +1,46 @@ +{ + "source": "frontend route parsing and current parity matrix", + "routes": { + "/wip-overview": { + "query_keys": [ + "workorder", + "lotid", + "package", + "type", + "status" + ], + "notes": "filters + status URL state must remain compatible" + }, + "/wip-detail": { + "query_keys": [ + "workcenter", + "workorder", + "lotid", + "package", + "type", + "status" + ], + "notes": "workcenter deep-link and back-link query continuity" + }, + "/hold-detail": { + "query_keys": [ + "reason" + ], + "notes": "reason required for normal access flow" + }, + "/resource-history": { + "query_keys": [ + "start_date", + "end_date", + "granularity", + "workcenter_groups", + "families", + "resource_ids", + "is_production", + "is_key", + "is_monitor" + ], + "notes": "query/export params must remain compatible" + } + } +} diff --git a/frontend/src/job-query/main.js b/frontend/src/job-query/main.js index 869fab8..0b94584 100644 --- a/frontend/src/job-query/main.js +++ b/frontend/src/job-query/main.js @@ -11,10 +11,27 @@ window.__FIELD_CONTRACTS__['job_query:txn_table'] = getPageContract('job_query', const jobTableFields = getPageContract('job_query', 'jobs_table'); const txnTableFields = getPageContract('job_query', 'txn_table'); +function toDataToken(value) { + return encodeURIComponent(safeText(value)); +} + +function fromDataToken(value) { + if (!value) { + return ''; + } + try { + return decodeURIComponent(value); + } catch (_error) { + return value; + } +} + function renderJobCell(job, apiKey) { if (apiKey === 'JOBSTATUS') { const value = safeText(job[apiKey]); - return `${value}`; + const classToken = safeText(value).replace(/[^A-Za-z0-9_-]/g, '_'); + const escaped = escapeHtml(value); + return `${escaped}`; } if (apiKey === 'CREATEDATE' || apiKey === 'COMPLETEDATE') { return formatDate(job[apiKey]); @@ -25,7 +42,9 @@ function renderJobCell(job, apiKey) { function renderTxnCell(txn, apiKey) { if (apiKey === 'FROMJOBSTATUS' || apiKey === 'JOBSTATUS') { const value = safeText(txn[apiKey], '-'); - return `${escapeHtml(value)}`; + const classToken = safeText(value).replace(/[^A-Za-z0-9_-]/g, '_'); + const escaped = escapeHtml(value); + return `${escaped}`; } if (apiKey === 'TXNDATE') { return formatDate(txn[apiKey]); @@ -48,6 +67,16 @@ function renderTxnCell(txn, apiKey) { loadEquipments(); setLast90Days(); + const equipmentList = document.getElementById('equipmentList'); + if (equipmentList) { + equipmentList.addEventListener('click', handleEquipmentListClick); + } + + const resultSection = document.getElementById('resultSection'); + if (resultSection) { + resultSection.addEventListener('click', handleResultSectionClick); + } + // Close dropdown when clicking outside document.addEventListener('click', (e) => { const dropdown = document.getElementById('equipmentDropdown'); @@ -94,20 +123,22 @@ function renderTxnCell(txn, apiKey) { const allSelected = selectedInGroup === groupIds.length; const someSelected = selectedInGroup > 0 && !allSelected; const escapedName = escapeHtml(workcenterName); - html += `
- + const workcenterToken = toDataToken(workcenterName); + html += `
+ ${escapedName} ${selectedInGroup}/${groupIds.length}
`; groupEquipments.forEach((eq) => { const isSelected = selectedEquipments.has(eq.RESOURCEID); - const resourceId = escapeHtml(safeText(eq.RESOURCEID)); + const resourceId = safeText(eq.RESOURCEID); + const resourceIdToken = toDataToken(resourceId); const resourceName = escapeHtml(safeText(eq.RESOURCENAME)); const familyName = escapeHtml(safeText(eq.RESOURCEFAMILYNAME)); html += ` -
- +
+
${resourceName}
${familyName}
@@ -120,6 +151,30 @@ function renderTxnCell(txn, apiKey) { container.innerHTML = html; } + function handleEquipmentListClick(event) { + const trigger = event.target.closest('[data-action]'); + if (!trigger) { + return; + } + + if (trigger.dataset.action === 'toggle-workcenter-group') { + const workcenterName = fromDataToken(trigger.dataset.workcenter); + if (!workcenterName) { + return; + } + toggleWorkcenterGroup(workcenterName); + return; + } + + if (trigger.dataset.action === 'toggle-equipment') { + const resourceId = fromDataToken(trigger.dataset.resourceId); + if (!resourceId) { + return; + } + toggleEquipment(resourceId); + } + } + // Toggle equipment dropdown function toggleEquipmentDropdown() { const dropdown = document.getElementById('equipmentDropdown'); @@ -299,8 +354,8 @@ function renderTxnCell(txn, apiKey) {
共 ${jobsData.length} 筆工單
- - + +
@@ -316,13 +371,14 @@ function renderTxnCell(txn, apiKey) { jobsData.forEach((job, idx) => { const isExpanded = expandedJobs.has(job.JOBID); + const jobIdToken = toDataToken(job.JOBID); const jobCells = jobTableFields .map((field) => `${renderJobCell(job, field.api_key)}`) .join(''); html += ` - @@ -355,6 +411,31 @@ function renderTxnCell(txn, apiKey) { void loadHistoriesBatched(pendingLoads); } + function handleResultSectionClick(event) { + const trigger = event.target.closest('[data-action]'); + if (!trigger) { + return; + } + + const action = trigger.dataset.action; + if (action === 'expand-all') { + expandAll(); + return; + } + if (action === 'collapse-all') { + collapseAll(); + return; + } + if (action === 'toggle-job-history') { + const idx = Number.parseInt(trigger.dataset.rowIndex || '', 10); + const jobId = fromDataToken(trigger.dataset.jobId); + if (!Number.isInteger(idx) || !jobId) { + return; + } + void toggleJobHistory(jobId, idx); + } + } + // Toggle job history async function toggleJobHistory(jobId, idx) { const txnRow = document.getElementById(`txn-row-${idx}`); diff --git a/openspec/changes/archive/2026-02-23-released-pages-production-hardening/.openspec.yaml b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/.openspec.yaml new file mode 100644 index 0000000..eac8ef7 --- /dev/null +++ b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-23 diff --git a/openspec/changes/archive/2026-02-23-released-pages-production-hardening/design.md b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/design.md new file mode 100644 index 0000000..60a04b4 --- /dev/null +++ b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/design.md @@ -0,0 +1,90 @@ +## Context + +Released 頁面已直接使用於生產,且現行部署為單層對外服務(無反向代理)。現況存在多個交叉風險: +- JSON 解析錯誤可能透過全域 exception handler 回落為 500。 +- 部分高成本查詢端點缺乏批量輸入與查詢筆數上限。 +- rate-limit client key 可能受 `X-Forwarded-For` spoofing 影響。 +- 設定載入在缺漏時存在偏寬鬆預設(含 API 可見性、環境模式)。 +- 記錄連線 URL 時可能暴露敏感資訊。 +- 前端仍有 inline handler 字串插值路徑。 + +本變更屬跨模組 hardening(routes/core/config/frontend/tests),且要求在不破壞 Released 正常流程下補齊安全與穩定性基線。 + +## Goals / Non-Goals + +**Goals:** +- 將 Released 高風險端點的輸入錯誤語義固定為可預期 4xx。 +- 對 batch / detail 查詢導入可設定的硬上限與拒絕策略。 +- 在無 proxy 預設下建立正確的 rate-limit 信任邊界。 +- 將生產安全設定調整為 fail-safe 預設並加入啟動檢查。 +- 移除已知前端 inline 插值風險點並補強測試,確保無回歸。 + +**Non-Goals:** +- 不重寫 Released 頁面的商業邏輯或資料模型。 +- 不改動 Oracle schema 或新增外部服務。 +- 不一次性移除全站所有 legacy inline script(以風險最高路徑優先)。 + +## Decisions + +### Decision 1: 建立一致的 JSON 輸入驗證邊界,將解析失敗明確轉為 4xx +- 選擇:在 Released 相關 JSON routes 採一致的 request parsing helper(含 content-type 與 malformed JSON 驗證),回傳 400/415;僅真正未預期例外才走 500。 +- 理由:修正「客戶端錯誤被誤判為服務端錯誤」並提升可觀測性。 +- 替代方案:維持各 route 自行 `get_json()` + 全域 handler。 + - 未採用原因:行為不一致且易再次回歸 500。 + +### Decision 2: 以設定驅動的輸入預算(input budget)治理高成本端點 +- 選擇:新增集中化上限設定(例如 `QUERY_TOOL_MAX_CONTAINER_IDS`、`RESOURCE_DETAIL_MAX_LIMIT`、`MAX_JSON_BODY_BYTES`),route 先驗證再呼叫 service。 +- 理由:避免 hardcode 分散、便於環境調優與壓測。 +- 替代方案:在 service 層被動截斷或依 DB timeout 自然保護。 + - 未採用原因:無法在入口即時拒絕,仍浪費應用資源。 + +### Decision 3: 以「預設不信任 proxy headers」實作 rate-limit identity +- 選擇:新增 `TRUST_PROXY_HEADERS=false` 預設;只有顯式開啟且來源符合 trusted proxy 條件時才使用 `X-Forwarded-For`。 +- 理由:符合當前無反向代理部署現況,避免 IP spoofing 使限流失效。 +- 替代方案:永遠信任 XFF。 + - 未採用原因:對外直連部署下可被任意偽造。 + +### Decision 4: 生產安全設定 fail-safe 與敏感資訊遮罩 +- 選擇:`api_public` 缺值或配置錯誤時預設 false;`SECRET_KEY` 等關鍵安全變數缺失時拒絕啟動或進入明確受限模式;所有 URL 型密鑰資訊在 log 遮罩。 +- 理由:把「配置失誤」從安全事件轉為可診斷的啟動錯誤。 +- 替代方案:保留寬鬆 fallback(例如預設公開 API)。 + - 未採用原因:與生產最小暴露原則衝突。 + +### Decision 5: 前端高風險 inline handler 先行替換為安全事件綁定 +- 選擇:針對 Released 且已觀察到風險的 job-query 動作欄位,改為 data attribute + addEventListener;避免 raw 字串 `onclick` 插值。 +- 理由:以最小變更降低 XSS/斷裂風險且不影響 UX。 +- 替代方案:一次性重構所有頁面事件綁定。 + - 未採用原因:變更面過大,不利快速風險收斂。 + +### Decision 6: 以「負向測試 + 既有契約測試」雙軌防回歸 +- 選擇:新增 hardening 專屬負向測試(invalid JSON、超量輸入、限流來源、secret redaction)並保留既有 released route 正向契約測試,兩者皆納入 CI gate。 +- 理由:確保防護生效且既有功能不被破壞。 +- 替代方案:僅補單元測試或手動驗證。 + - 未採用原因:無法長期防止行為漂移。 + +## Risks / Trade-offs + +- [Risk] 新增 4xx 驗證可能影響少量既有錯誤處理流程 → Mitigation: 僅對 JSON-only endpoint 啟用,並以契約測試固定成功路徑。 +- [Risk] 輸入上限過低可能影響查詢體驗 → Mitigation: 上限參數化並透過壓測/實際流量校準。 +- [Risk] fail-safe 設定可能在配置不完整時阻擋啟動 → Mitigation: 發布前檢查清單與啟動時清楚錯誤訊息。 +- [Risk] 前端事件綁定改動造成局部互動差異 → Mitigation: 補 UI 行為測試與手動 smoke 驗證。 + +## Migration Plan + +1. 新增設定鍵與預設值(輸入上限、proxy trust、安全啟動檢查),保留清楚註解與環境文件。 +2. 先改 route 層 JSON 驗證與批量上限檢查,再補 service 防線(雙層保護)。 +3. 更新 rate-limit client identity resolver,預設走 `remote_addr`。 +4. 加入 Redis URL log redaction 與 page registry fail-safe 預設。 +5. 調整 job-query 前端事件綁定,移除高風險 inline 插值。 +6. 補齊測試:負向 API、限流信任邊界、設定 fail-safe、log redaction、既有 released route 契約。 +7. CI 全綠後部署;若出現非預期拒絕,僅允許透過設定值調整上限,不回退安全語義。 + +Rollback Strategy: +- 若發生突發相容性問題,優先調整上限配置與 trusted proxy 配置; +- 嚴禁回退到「信任任意 XFF」或「invalid JSON 回 500」行為; +- 必要時暫時放寬單一端點上限,但保留防護機制本身。 + +## Open Questions + +- `container_ids` 與 `resource detail limit` 的正式預設值是否以現網 P95 請求分佈定版(例如 200 / 500)? +- trusted proxy 是否需要 CIDR allowlist(而非單純 bool)以支援未來拓樸演進? diff --git a/openspec/changes/archive/2026-02-23-released-pages-production-hardening/proposal.md b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/proposal.md new file mode 100644 index 0000000..c5dba7f --- /dev/null +++ b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/proposal.md @@ -0,0 +1,37 @@ +## Why + +Released 頁面目前直接套用到生產環境,且部署型態為無反向代理的單層對外服務;現況在 API 輸入驗證、流量防護、設定安全預設、與錯誤處理上仍有可導致 500、資源耗盡或安全邊界被繞過的風險。需要以一次性治理方式補齊基線,並建立可重複執行的無回歸驗證,避免修正後再次退化。 + +## What Changes + +- 統一 Released 頁面相關高成本 API 的輸入驗證與錯誤語義:非 JSON 或格式錯誤請求回覆 4xx,不再落入 500。 +- 為 query-tool 與 resource 等批次/明細查詢加入明確上限(批量 ID、limit、payload size)與拒絕策略,降低 DoS 與慢查風險。 +- 強化 rate-limit 客戶端識別信任邊界:在無 trusted proxy 情境下不可直接信任 `X-Forwarded-For`。 +- 對生產安全設定採 fail-safe 預設:`api_public`、`FLASK_ENV`、`SECRET_KEY`、Redis URL log masking 等。 +- 收斂前端可注入風險(如 inline handler 字串插值)與 CSP 風險設定,降低 XSS 面。 +- 建立 Released 頁面專屬無回歸驗證矩陣(正向、負向、壓力邊界、契約),納入 CI gate。 + +## Capabilities + +### New Capabilities +- `released-pages-production-hardening`: 定義 Released 頁面在生產環境的輸入驗證、資源保護、信任邊界、安全預設與回歸防線要求。 + +### Modified Capabilities +- None. + +## Impact + +- Affected code: + - `src/mes_dashboard/routes/job_query_routes.py` + - `src/mes_dashboard/routes/query_tool_routes.py` + - `src/mes_dashboard/routes/resource_routes.py` + - `src/mes_dashboard/routes/hold_routes.py` + - `src/mes_dashboard/routes/wip_routes.py` + - `src/mes_dashboard/core/rate_limit.py` + - `src/mes_dashboard/core/redis_client.py` + - `src/mes_dashboard/config/settings.py` + - `src/mes_dashboard/app.py` + - `frontend/src/job-query/main.js` + - `data/page_status.json` +- APIs/routes: Released route 對應 API(包含 `/api/query-tool/*`, `/api/job-query/*`, `/api/resource/*` 等)會新增/明確化 4xx 與 429 邊界行為。 +- Tests/quality gates: 新增與擴充 Released 頁面 API 的負向驗證、限流、上限邊界與模板整合回歸測試;CI 需納入通過條件。 diff --git a/openspec/changes/archive/2026-02-23-released-pages-production-hardening/specs/released-pages-production-hardening/spec.md b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/specs/released-pages-production-hardening/spec.md new file mode 100644 index 0000000..f0a7483 --- /dev/null +++ b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/specs/released-pages-production-hardening/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Released Query APIs SHALL Return 4xx for Invalid JSON Inputs +Released 頁面對應的 JSON API 在收到非 JSON、Malformed JSON、或型別不符 payload 時,MUST 回覆可預期的 4xx 錯誤,且 MUST NOT 因 JSON 解析失敗回落為 500。 + +#### Scenario: Non-JSON request to JSON-only endpoint +- **WHEN** client 以 `Content-Type: text/plain` 或缺少 JSON body 呼叫 JSON-only endpoint(例如 `/api/query-tool/*`、`/api/job-query/*`、`/api/resource/detail`) +- **THEN** endpoint MUST 回覆 400 或 415,並提供一致的錯誤訊息 +- **THEN** service layer MUST NOT 執行高成本查詢 + +#### Scenario: Malformed JSON payload +- **WHEN** client 送出無法解析的 JSON 內容 +- **THEN** endpoint MUST 回覆 400 +- **THEN** response MUST 指出 payload 格式錯誤,而非 generic 500 + +### Requirement: High-Cost Batch Inputs SHALL Enforce Hard Upper Bounds +Released 頁面高成本查詢端點 MUST 對批量輸入與查詢筆數上限施加硬限制,避免單次請求造成過量資料讀取或計算。 + +#### Scenario: Query-tool batch container IDs exceed limit +- **WHEN** `container_ids` 數量超過設定上限 +- **THEN** endpoint MUST 回覆 400 或 413,且 MUST 附帶可操作的上限資訊 +- **THEN** backend MUST NOT 執行 Oracle/Redis 高成本查詢流程 + +#### Scenario: Resource detail limit exceeds limit +- **WHEN** `/api/resource/detail` 的 `limit` 超過設定上限 +- **THEN** endpoint MUST 拒絕請求或安全夾制至上限,並在契約中明確定義行為 +- **THEN** response 行為 MUST 於測試中固定化,避免版本漂移 + +### Requirement: Rate-Limit Client Identity SHALL Respect Trust Boundary +Rate limiting 的 client identity 解析 MUST 依部署信任邊界運作,未啟用 trusted proxy 時 MUST NOT 直接信任 `X-Forwarded-For`。 + +#### Scenario: Direct internet deployment without reverse proxy +- **WHEN** 服務直接對外且未啟用 trusted proxy 模式 +- **THEN** rate-limit key MUST 使用 `remote_addr`(或等價來源) +- **THEN** 來自 request header 的 `X-Forwarded-For` MUST 被忽略 + +#### Scenario: Deployment with trusted reverse proxy enabled +- **WHEN** 系統明確配置 trusted proxy 名單或模式 +- **THEN** rate-limit key MAY 使用 `X-Forwarded-For` 的可信 client IP +- **THEN** 非可信來源 MUST 回退至 `remote_addr` + +### Requirement: Production Security Defaults SHALL Fail Safe +生產設定在缺漏或格式錯誤時 MUST 採 fail-safe 預設,避免 API 無意外暴露或低安全模式啟動。 + +#### Scenario: page status config missing or invalid +- **WHEN** `page_status.json` 缺失、破損或缺少 `api_public` 設定 +- **THEN** runtime MUST 預設為 API 非公開(`api_public=false`) +- **THEN** 需要明確配置才可開啟公開 API 行為 + +#### Scenario: runtime environment variables incomplete +- **WHEN** 生產啟動缺少關鍵安全變數(例如 `SECRET_KEY`) +- **THEN** 系統 MUST 以安全方式拒絕啟動或進入受限模式,且輸出可診斷訊息 + +### Requirement: Sensitive Configuration Values SHALL Be Redacted in Logs +任何含憑證的連線字串(例如 Redis URL)在 log 輸出時 MUST 進行遮罩,避免密碼外洩。 + +#### Scenario: Redis URL includes password +- **WHEN** 應用程式記錄 Redis 連線設定 +- **THEN** log 中的 URL MUST 隱藏密碼(例如 `redis://***@host:port/db`) +- **THEN** 原始明文密碼 MUST NOT 出現在任何應用層日誌 + +### Requirement: Released Frontend Views SHALL Avoid Unsafe Inline Interpolation +Released 頁面前端 MUST 避免將不受信資料直接插入 inline JavaScript 或 HTML 屬性字串,降低 XSS 與 handler 斷裂風險。 + +#### Scenario: Rendering action controls with user-derived values +- **WHEN** 前端渲染按鈕或互動控制(例如 job-query 操作欄)且內容含資料列值 +- **THEN** MUST 透過安全資料綁定(data-* attribute 或事件監聽)實作 +- **THEN** MUST NOT 依賴 raw string `onclick="...${value}..."` 拼接 + +### Requirement: Released Hardening SHALL Be Protected by Regression Gates +本次 hardening 的行為 MUST 由自動化測試固定,並納入 CI gate,避免日後回歸。 + +#### Scenario: Negative-path regression suite execution +- **WHEN** CI 執行 Released 頁面 API 測試 +- **THEN** MUST 覆蓋 invalid JSON、超量輸入、rate-limit、security default、與 log redaction 斷言 +- **THEN** 任一關鍵斷言失敗 MUST 阻擋合併 + +#### Scenario: Existing released behavior parity +- **WHEN** hardening 變更部署後執行既有 Released route 測試 +- **THEN** 成功路徑與既有回應契約 MUST 維持相容 +- **THEN** 僅新增已定義的防護錯誤路徑(4xx/429) diff --git a/openspec/changes/archive/2026-02-23-released-pages-production-hardening/tasks.md b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/tasks.md new file mode 100644 index 0000000..3c48163 --- /dev/null +++ b/openspec/changes/archive/2026-02-23-released-pages-production-hardening/tasks.md @@ -0,0 +1,34 @@ +## 1. Config and Core Safety Baseline + +- [x] 1.1 Add centralized hardening config keys (`TRUST_PROXY_HEADERS`, trusted proxy source config, JSON/body/input limits) with production-safe defaults. +- [x] 1.2 Change page registry fallback behavior so `api_public` defaults to false when config is missing/invalid. +- [x] 1.3 Implement secret redaction utility for connection-string logging and apply it to Redis URL logs. +- [x] 1.4 Enforce startup validation for required production security variables (including `SECRET_KEY`) with actionable diagnostics. +- [x] 1.5 Update environment documentation (`.env.example`/README/deploy docs) to match new hardening settings. + +## 2. Released API Input Validation and Budget Guards + +- [x] 2.1 Introduce a shared JSON request parsing/validation helper and adopt it in released JSON-only endpoints (`query-tool`, `job-query`, `resource` related routes). +- [x] 2.2 Ensure invalid/malformed/non-JSON payloads return deterministic 400/415 and do not fall through to generic 500 handlers. +- [x] 2.3 Add configurable hard caps for query-tool batch inputs (including `container_ids`) and reject overflow requests before service execution. +- [x] 2.4 Add configurable `limit` bounds for `/api/resource/detail` and normalize/reject invalid pagination limits consistently. +- [x] 2.5 Fix released route numeric query parsing edge cases to avoid `TypeError`/500 regressions. + +## 3. Rate-Limit Trust Boundary Hardening + +- [x] 3.1 Refactor rate-limit client identity resolution to ignore `X-Forwarded-For` by default and use `remote_addr` in direct-exposure deployments. +- [x] 3.2 Add trusted-proxy mode behavior so forwarded IP is used only when explicit trust configuration is enabled. +- [x] 3.3 Add tests for spoofed header attempts, direct mode behavior, and trusted-proxy behavior. + +## 4. Frontend Injection-Surface Reduction + +- [x] 4.1 Refactor `job-query` action rendering to remove raw inline `onclick` interpolation and use safe event binding/data attributes. +- [x] 4.2 Review and tighten applicable CSP/script-safety configuration for released routes without breaking current module/fallback loading. +- [x] 4.3 Add frontend/template tests to lock down safe rendering behavior for quoted/special-character data. + +## 5. Regression Gates and Verification + +- [x] 5.1 Add negative-path tests for invalid JSON, oversized batch input, bounded `limit`, and no-service-call-on-reject behavior. +- [x] 5.2 Add config hardening tests for `api_public` fail-safe fallback, production env validation, and Redis URL redaction. +- [x] 5.3 Run released-route focused pytest suite and update/repair affected contract tests to reflect explicit new 4xx/429 boundaries only. +- [x] 5.4 Ensure CI requires the new hardening test set to pass before merge. diff --git a/openspec/specs/released-pages-production-hardening/spec.md b/openspec/specs/released-pages-production-hardening/spec.md new file mode 100644 index 0000000..184f71b --- /dev/null +++ b/openspec/specs/released-pages-production-hardening/spec.md @@ -0,0 +1,85 @@ +# released-pages-production-hardening Specification + +## Purpose +TBD - created by archiving change released-pages-production-hardening. Update Purpose after archive. +## Requirements +### Requirement: Released Query APIs SHALL Return 4xx for Invalid JSON Inputs +Released 頁面對應的 JSON API 在收到非 JSON、Malformed JSON、或型別不符 payload 時,MUST 回覆可預期的 4xx 錯誤,且 MUST NOT 因 JSON 解析失敗回落為 500。 + +#### Scenario: Non-JSON request to JSON-only endpoint +- **WHEN** client 以 `Content-Type: text/plain` 或缺少 JSON body 呼叫 JSON-only endpoint(例如 `/api/query-tool/*`、`/api/job-query/*`、`/api/resource/detail`) +- **THEN** endpoint MUST 回覆 400 或 415,並提供一致的錯誤訊息 +- **THEN** service layer MUST NOT 執行高成本查詢 + +#### Scenario: Malformed JSON payload +- **WHEN** client 送出無法解析的 JSON 內容 +- **THEN** endpoint MUST 回覆 400 +- **THEN** response MUST 指出 payload 格式錯誤,而非 generic 500 + +### Requirement: High-Cost Batch Inputs SHALL Enforce Hard Upper Bounds +Released 頁面高成本查詢端點 MUST 對批量輸入與查詢筆數上限施加硬限制,避免單次請求造成過量資料讀取或計算。 + +#### Scenario: Query-tool batch container IDs exceed limit +- **WHEN** `container_ids` 數量超過設定上限 +- **THEN** endpoint MUST 回覆 400 或 413,且 MUST 附帶可操作的上限資訊 +- **THEN** backend MUST NOT 執行 Oracle/Redis 高成本查詢流程 + +#### Scenario: Resource detail limit exceeds limit +- **WHEN** `/api/resource/detail` 的 `limit` 超過設定上限 +- **THEN** endpoint MUST 拒絕請求或安全夾制至上限,並在契約中明確定義行為 +- **THEN** response 行為 MUST 於測試中固定化,避免版本漂移 + +### Requirement: Rate-Limit Client Identity SHALL Respect Trust Boundary +Rate limiting 的 client identity 解析 MUST 依部署信任邊界運作,未啟用 trusted proxy 時 MUST NOT 直接信任 `X-Forwarded-For`。 + +#### Scenario: Direct internet deployment without reverse proxy +- **WHEN** 服務直接對外且未啟用 trusted proxy 模式 +- **THEN** rate-limit key MUST 使用 `remote_addr`(或等價來源) +- **THEN** 來自 request header 的 `X-Forwarded-For` MUST 被忽略 + +#### Scenario: Deployment with trusted reverse proxy enabled +- **WHEN** 系統明確配置 trusted proxy 名單或模式 +- **THEN** rate-limit key MAY 使用 `X-Forwarded-For` 的可信 client IP +- **THEN** 非可信來源 MUST 回退至 `remote_addr` + +### Requirement: Production Security Defaults SHALL Fail Safe +生產設定在缺漏或格式錯誤時 MUST 採 fail-safe 預設,避免 API 無意外暴露或低安全模式啟動。 + +#### Scenario: page status config missing or invalid +- **WHEN** `page_status.json` 缺失、破損或缺少 `api_public` 設定 +- **THEN** runtime MUST 預設為 API 非公開(`api_public=false`) +- **THEN** 需要明確配置才可開啟公開 API 行為 + +#### Scenario: runtime environment variables incomplete +- **WHEN** 生產啟動缺少關鍵安全變數(例如 `SECRET_KEY`) +- **THEN** 系統 MUST 以安全方式拒絕啟動或進入受限模式,且輸出可診斷訊息 + +### Requirement: Sensitive Configuration Values SHALL Be Redacted in Logs +任何含憑證的連線字串(例如 Redis URL)在 log 輸出時 MUST 進行遮罩,避免密碼外洩。 + +#### Scenario: Redis URL includes password +- **WHEN** 應用程式記錄 Redis 連線設定 +- **THEN** log 中的 URL MUST 隱藏密碼(例如 `redis://***@host:port/db`) +- **THEN** 原始明文密碼 MUST NOT 出現在任何應用層日誌 + +### Requirement: Released Frontend Views SHALL Avoid Unsafe Inline Interpolation +Released 頁面前端 MUST 避免將不受信資料直接插入 inline JavaScript 或 HTML 屬性字串,降低 XSS 與 handler 斷裂風險。 + +#### Scenario: Rendering action controls with user-derived values +- **WHEN** 前端渲染按鈕或互動控制(例如 job-query 操作欄)且內容含資料列值 +- **THEN** MUST 透過安全資料綁定(data-* attribute 或事件監聽)實作 +- **THEN** MUST NOT 依賴 raw string `onclick="...${value}..."` 拼接 + +### Requirement: Released Hardening SHALL Be Protected by Regression Gates +本次 hardening 的行為 MUST 由自動化測試固定,並納入 CI gate,避免日後回歸。 + +#### Scenario: Negative-path regression suite execution +- **WHEN** CI 執行 Released 頁面 API 測試 +- **THEN** MUST 覆蓋 invalid JSON、超量輸入、rate-limit、security default、與 log redaction 斷言 +- **THEN** 任一關鍵斷言失敗 MUST 阻擋合併 + +#### Scenario: Existing released behavior parity +- **WHEN** hardening 變更部署後執行既有 Released route 測試 +- **THEN** 成功路徑與既有回應契約 MUST 維持相容 +- **THEN** 僅新增已定義的防護錯誤路徑(4xx/429) + diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index f4a5f2e..054feac 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -114,11 +114,15 @@ def _is_production_env(app: Flask) -> bool: return env_value in {"prod", "production"} -def _build_security_headers(production: bool) -> dict[str, str]: +def _build_security_headers(production: bool, *, allow_unsafe_eval: bool = False) -> dict[str, str]: + script_directives = ["'self'", "'unsafe-inline'"] + if allow_unsafe_eval: + script_directives.append("'unsafe-eval'") + headers = { "Content-Security-Policy": ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + f"script-src {' '.join(script_directives)}; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: blob:; " "font-src 'self' data:; " @@ -155,6 +159,42 @@ def _resolve_secret_key(app: Flask) -> str: return "dev-local-only-secret-key" +def _validate_production_security_settings(app: Flask) -> None: + """Validate production security-sensitive runtime settings.""" + if not _is_production_env(app): + return + + trust_proxy_headers = resolve_bool_flag( + "TRUST_PROXY_HEADERS", + config=app.config, + default=bool(app.config.get("TRUST_PROXY_HEADERS", False)), + ) + if trust_proxy_headers: + configured_sources = os.getenv("TRUSTED_PROXY_IPS") + if configured_sources is None: + configured_sources = app.config.get("TRUSTED_PROXY_IPS") + if isinstance(configured_sources, str): + trusted_sources = tuple( + part.strip() + for part in configured_sources.split(",") + if part.strip() + ) + else: + trusted_sources = tuple(configured_sources or ()) + if not trusted_sources: + raise RuntimeError( + "TRUST_PROXY_HEADERS=true requires TRUSTED_PROXY_IPS in production." + ) + + +def _resolve_csp_allow_unsafe_eval(app: Flask) -> bool: + return resolve_bool_flag( + "CSP_ALLOW_UNSAFE_EVAL", + config=app.config, + default=bool(app.config.get("CSP_ALLOW_UNSAFE_EVAL", False)), + ) + + def _resolve_portal_spa_enabled(app: Flask) -> bool: """Resolve cutover flag for SPA shell navigation. @@ -367,6 +407,7 @@ def create_app(config_name: str | None = None) -> Flask: # Session configuration with environment-aware secret validation. app.secret_key = _resolve_secret_key(app) app.config["SECRET_KEY"] = app.secret_key + _validate_production_security_settings(app) # Session cookie security settings # SECURE: Only send cookie over HTTPS in production. @@ -380,7 +421,10 @@ def create_app(config_name: str | None = None) -> Flask: _configure_logging(app) _validate_runtime_contract(app) _validate_in_scope_asset_readiness(app) - security_headers = _build_security_headers(_is_production_env(app)) + security_headers = _build_security_headers( + _is_production_env(app), + allow_unsafe_eval=_resolve_csp_allow_unsafe_eval(app), + ) # Route-level cache backend (L1 memory + optional L2 Redis) app.extensions["cache"] = create_default_cache_backend() diff --git a/src/mes_dashboard/config/settings.py b/src/mes_dashboard/config/settings.py index 5ba65f5..e736e4a 100644 --- a/src/mes_dashboard/config/settings.py +++ b/src/mes_dashboard/config/settings.py @@ -27,6 +27,13 @@ def _bool_env(name: str, default: bool) -> bool: return value.strip().lower() in {"1", "true", "yes", "on"} +def _csv_env(name: str, default: str = "") -> tuple[str, ...]: + value = os.getenv(name, default) + if not value: + return tuple() + return tuple(item.strip() for item in value.split(",") if item.strip()) + + class Config: """Base configuration.""" @@ -51,6 +58,15 @@ class Config: CSRF_ENABLED = _bool_env("CSRF_ENABLED", True) PORTAL_SPA_ENABLED = _bool_env("PORTAL_SPA_ENABLED", True) + # Hardening configuration (safe-by-default) + MAX_JSON_BODY_BYTES = _int_env("MAX_JSON_BODY_BYTES", 262144) # 256 KB + QUERY_TOOL_MAX_CONTAINER_IDS = _int_env("QUERY_TOOL_MAX_CONTAINER_IDS", 200) + RESOURCE_DETAIL_DEFAULT_LIMIT = _int_env("RESOURCE_DETAIL_DEFAULT_LIMIT", 500) + RESOURCE_DETAIL_MAX_LIMIT = _int_env("RESOURCE_DETAIL_MAX_LIMIT", 500) + TRUST_PROXY_HEADERS = _bool_env("TRUST_PROXY_HEADERS", False) + TRUSTED_PROXY_IPS = _csv_env("TRUSTED_PROXY_IPS") + CSP_ALLOW_UNSAFE_EVAL = _bool_env("CSP_ALLOW_UNSAFE_EVAL", False) + # Session configuration PERMANENT_SESSION_LIFETIME = _int_env("SESSION_LIFETIME", 28800) # 8 hours @@ -117,7 +133,7 @@ class TestingConfig(Config): def get_config(env: str | None = None) -> Type[Config]: """Select config class based on environment name.""" - value = (env or os.getenv("FLASK_ENV", "development")).lower() + value = (env or os.getenv("FLASK_ENV", "production")).lower() if value in {"prod", "production"}: return ProductionConfig if value in {"test", "testing"}: diff --git a/src/mes_dashboard/core/rate_limit.py b/src/mes_dashboard/core/rate_limit.py index f982d42..0faf678 100644 --- a/src/mes_dashboard/core/rate_limit.py +++ b/src/mes_dashboard/core/rate_limit.py @@ -6,6 +6,7 @@ from __future__ import annotations import os import threading import time +from ipaddress import ip_address, ip_network from collections import defaultdict, deque from functools import wraps from typing import Callable, Deque @@ -29,11 +30,66 @@ def _env_int(name: str, default: int) -> int: return max(value, 1) +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _trusted_proxy_networks() -> list: + raw = os.getenv("TRUSTED_PROXY_IPS", "") + if not raw: + return [] + + networks = [] + for token in raw.split(","): + candidate = token.strip() + if not candidate: + continue + try: + if "/" in candidate: + networks.append(ip_network(candidate, strict=False)) + else: + if ":" in candidate: + networks.append(ip_network(f"{candidate}/128", strict=False)) + else: + networks.append(ip_network(f"{candidate}/32", strict=False)) + except ValueError: + continue + return networks + + +def _is_trusted_proxy_source(remote_addr: str | None) -> bool: + if not _env_bool("TRUST_PROXY_HEADERS", False): + return False + if not remote_addr: + return False + + networks = _trusted_proxy_networks() + if not networks: + # Explicit proxy trust mode requires explicit trusted source list. + return False + + try: + remote_ip = ip_address(remote_addr.strip()) + except ValueError: + return False + + return any(remote_ip in network for network in networks) + + def _client_identifier() -> str: - forwarded = request.headers.get("X-Forwarded-For", "").strip() - if forwarded: - return forwarded.split(",")[0].strip() - return request.remote_addr or "unknown" + remote = request.remote_addr + if _is_trusted_proxy_source(remote): + forwarded = request.headers.get("X-Forwarded-For", "").strip() + if forwarded: + candidate = forwarded.split(",")[0].strip() + try: + return str(ip_address(candidate)) + except ValueError: + pass + return remote or "unknown" def check_and_record( diff --git a/src/mes_dashboard/core/redis_client.py b/src/mes_dashboard/core/redis_client.py index a0ad179..806c299 100644 --- a/src/mes_dashboard/core/redis_client.py +++ b/src/mes_dashboard/core/redis_client.py @@ -3,9 +3,10 @@ from __future__ import annotations -import logging -import os -from typing import Optional +import logging +import os +from typing import Optional +from urllib.parse import urlsplit, urlunsplit import redis @@ -23,10 +24,35 @@ REDIS_KEY_PREFIX = os.getenv('REDIS_KEY_PREFIX', 'mes_wip') # Redis Client Singleton # ============================================================ -_REDIS_CLIENT: Optional[redis.Redis] = None - - -def get_redis_client() -> Optional[redis.Redis]: +_REDIS_CLIENT: Optional[redis.Redis] = None + + +def redact_connection_url(url: str) -> str: + """Redact credentials in URL-like connection strings.""" + if not url: + return url + try: + parsed = urlsplit(url) + except Exception: + return url + + netloc = parsed.netloc + if "@" not in netloc: + return url + + credentials, host = netloc.rsplit("@", 1) + if ":" in credentials: + user, _password = credentials.split(":", 1) + masked = f"{user}:***" if user else "***" + else: + masked = "***" + + return urlunsplit( + (parsed.scheme, f"{masked}@{host}", parsed.path, parsed.query, parsed.fragment) + ) + + +def get_redis_client() -> Optional[redis.Redis]: """Get Redis client with connection pooling and health check. Returns: @@ -47,10 +73,10 @@ def get_redis_client() -> Optional[redis.Redis]: socket_connect_timeout=5, retry_on_timeout=True, health_check_interval=30 - ) - # Test connection - _REDIS_CLIENT.ping() - logger.info(f"Redis client connected to {REDIS_URL}") + ) + # Test connection + _REDIS_CLIENT.ping() + logger.info("Redis client connected to %s", redact_connection_url(REDIS_URL)) except redis.RedisError as e: logger.warning(f"Failed to connect to Redis: {e}") _REDIS_CLIENT = None diff --git a/src/mes_dashboard/core/request_validation.py b/src/mes_dashboard/core/request_validation.py new file mode 100644 index 0000000..844fed9 --- /dev/null +++ b/src/mes_dashboard/core/request_validation.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""Request validation helpers for API routes.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +from flask import current_app, request + + +@dataclass(frozen=True) +class JsonPayloadError: + message: str + status_code: int + + +def _resolve_max_json_body_bytes(explicit_max: int | None = None) -> int: + if explicit_max is not None: + return max(int(explicit_max), 1) + try: + value = int(current_app.config.get("MAX_JSON_BODY_BYTES", 262144)) + return max(value, 1) + except Exception: + pass + try: + return max(int(os.getenv("MAX_JSON_BODY_BYTES", "262144")), 1) + except Exception: + return 262144 + + +def parse_json_payload( + *, + require_object: bool = True, + require_non_empty_object: bool = False, + max_body_bytes: int | None = None, +) -> tuple[Any | None, JsonPayloadError | None]: + """Parse and validate JSON request payload with deterministic 4xx errors.""" + content_length = request.content_length + max_bytes = _resolve_max_json_body_bytes(max_body_bytes) + if content_length is not None and content_length > max_bytes: + return None, JsonPayloadError( + f"請求內容過大,限制 {max_bytes} bytes", + 413, + ) + + if not request.is_json: + return None, JsonPayloadError( + "Content-Type 必須為 application/json", + 415, + ) + + payload = request.get_json(silent=True) + if payload is None: + return None, JsonPayloadError("JSON 格式錯誤", 400) + + if require_object and not isinstance(payload, dict): + return None, JsonPayloadError("JSON 內容必須為物件", 400) + + if require_non_empty_object and isinstance(payload, dict) and not payload: + return None, JsonPayloadError("請求內容不可為空", 400) + + return payload, None diff --git a/src/mes_dashboard/routes/hold_routes.py b/src/mes_dashboard/routes/hold_routes.py index 718dde4..37ee2aa 100644 --- a/src/mes_dashboard/routes/hold_routes.py +++ b/src/mes_dashboard/routes/hold_routes.py @@ -154,15 +154,18 @@ def api_hold_detail_lots(): if not reason: return jsonify({'success': False, 'error': '缺少必要參數: reason'}), 400 - workcenter = request.args.get('workcenter', '').strip() or None - package = request.args.get('package', '').strip() or None - age_range = request.args.get('age_range', '').strip() or None + workcenter = request.args.get('workcenter', '').strip() or None + package = request.args.get('package', '').strip() or None + age_range = request.args.get('age_range', '').strip() or None include_dummy = parse_bool_query(request.args.get('include_dummy')) - page = request.args.get('page', 1, type=int) - per_page = min(request.args.get('per_page', 50, type=int), 200) - - if page < 1: - page = 1 + page = request.args.get('page', 1, type=int) + per_page_value = request.args.get('per_page', 50, type=int) + if per_page_value is None: + per_page_value = 50 + per_page = min(max(per_page_value, 1), 200) + + if page is None or page < 1: + page = 1 # Validate age_range parameter if age_range and age_range not in ('0-1', '1-3', '3-7', '7+'): diff --git a/src/mes_dashboard/routes/job_query_routes.py b/src/mes_dashboard/routes/job_query_routes.py index 890ca8a..17e7e99 100644 --- a/src/mes_dashboard/routes/job_query_routes.py +++ b/src/mes_dashboard/routes/job_query_routes.py @@ -13,6 +13,7 @@ from flask import Blueprint, jsonify, request, Response, render_template from mes_dashboard.core.rate_limit import configured_rate_limit from mes_dashboard.core.modernization_policy import maybe_redirect_to_canonical_shell +from mes_dashboard.core.request_validation import parse_json_payload from mes_dashboard.services.job_query_service import ( get_jobs_by_resources, get_job_txn_history, @@ -98,7 +99,7 @@ def get_resources(): @job_query_bp.route('/api/job-query/jobs', methods=['POST']) @_JOB_QUERY_RATE_LIMIT -def query_jobs(): +def query_jobs(): """Query jobs for selected resources. Expects JSON body: @@ -110,9 +111,11 @@ def query_jobs(): Returns job list. """ - data = request.get_json() - - resource_ids = data.get('resource_ids', []) + data, payload_error = parse_json_payload(require_non_empty_object=True) + if payload_error is not None: + return jsonify({'error': payload_error.message}), payload_error.status_code + + resource_ids = data.get('resource_ids', []) start_date = data.get('start_date') end_date = data.get('end_date') @@ -159,7 +162,7 @@ def query_job_txn_history(job_id: str): @job_query_bp.route('/api/job-query/export', methods=['POST']) @_JOB_EXPORT_RATE_LIMIT -def export_jobs(): +def export_jobs(): """Export jobs with full transaction history as CSV. Expects JSON body: @@ -171,9 +174,11 @@ def export_jobs(): Returns streaming CSV response. """ - data = request.get_json() - - resource_ids = data.get('resource_ids', []) + data, payload_error = parse_json_payload(require_non_empty_object=True) + if payload_error is not None: + return jsonify({'error': payload_error.message}), payload_error.status_code + + resource_ids = data.get('resource_ids', []) start_date = data.get('start_date') end_date = data.get('end_date') diff --git a/src/mes_dashboard/routes/query_tool_routes.py b/src/mes_dashboard/routes/query_tool_routes.py index cc2c6b9..1c8b58f 100644 --- a/src/mes_dashboard/routes/query_tool_routes.py +++ b/src/mes_dashboard/routes/query_tool_routes.py @@ -9,14 +9,15 @@ Contains Flask Blueprint for batch tracing and equipment period query endpoints: - CSV export functionality """ -import hashlib - -from flask import Blueprint, jsonify, request, Response, render_template - -from mes_dashboard.core.cache import cache_get, cache_set -from mes_dashboard.core.modernization_policy import maybe_redirect_to_canonical_shell -from mes_dashboard.core.rate_limit import configured_rate_limit -from mes_dashboard.services.query_tool_service import ( +import hashlib + +from flask import Blueprint, jsonify, request, Response, render_template, current_app + +from mes_dashboard.core.cache import cache_get, cache_set +from mes_dashboard.core.modernization_policy import maybe_redirect_to_canonical_shell +from mes_dashboard.core.rate_limit import configured_rate_limit +from mes_dashboard.core.request_validation import parse_json_payload +from mes_dashboard.services.query_tool_service import ( resolve_lots, get_lot_history, get_lot_history_batch, @@ -86,6 +87,21 @@ _QUERY_TOOL_EXPORT_RATE_LIMIT = configured_rate_limit( ) +def _query_tool_max_container_ids() -> int: + try: + value = int(current_app.config.get("QUERY_TOOL_MAX_CONTAINER_IDS", 200)) + except Exception: + value = 200 + return max(value, 1) + + +def _reject_if_batch_too_large(container_ids: list[str]): + max_ids = _query_tool_max_container_ids() + if len(container_ids) <= max_ids: + return None + return jsonify({'error': f'container_ids 數量不可超過 {max_ids} 筆'}), 413 + + def _format_lot_materials_export_rows(rows): """Normalize LOT material export columns for UI/CSV consistency.""" normalized_rows = [] @@ -146,7 +162,7 @@ def query_tool_page(): @query_tool_bp.route('/api/query-tool/resolve', methods=['POST']) @_QUERY_TOOL_RESOLVE_RATE_LIMIT -def resolve_lot_input(): +def resolve_lot_input(): """Resolve user input to CONTAINERID list. Expects JSON body: @@ -163,12 +179,11 @@ def resolve_lot_input(): "not_found": ["value3"] } """ - data = request.get_json() - - if not data: - return jsonify({'error': '請求內容不可為空'}), 400 - - input_type = data.get('input_type') + data, payload_error = parse_json_payload(require_non_empty_object=True) + if payload_error is not None: + return jsonify({'error': payload_error.message}), payload_error.status_code + + input_type = data.get('input_type') values = data.get('values', []) # Validate input type @@ -213,7 +228,7 @@ def resolve_lot_input(): @query_tool_bp.route('/api/query-tool/lot-history', methods=['GET']) @_QUERY_TOOL_HISTORY_RATE_LIMIT -def query_lot_history(): +def query_lot_history(): """Query production history for one or more LOTs. Query params: @@ -236,12 +251,15 @@ def query_lot_history(): ] # Batch mode: container_ids takes precedence - if container_ids_param: - cids = [c.strip() for c in container_ids_param.split(',') if c.strip()] - if not cids: - return jsonify({'error': '請指定 CONTAINERID'}), 400 - result = get_lot_history_batch(cids, workcenter_groups=workcenter_groups) - elif container_id: + if container_ids_param: + cids = [c.strip() for c in container_ids_param.split(',') if c.strip()] + if not cids: + return jsonify({'error': '請指定 CONTAINERID'}), 400 + too_large = _reject_if_batch_too_large(cids) + if too_large is not None: + return too_large + result = get_lot_history_batch(cids, workcenter_groups=workcenter_groups) + elif container_id: result = get_lot_history(container_id, workcenter_groups=workcenter_groups) else: return jsonify({'error': '請指定 CONTAINERID'}), 400 @@ -315,12 +333,15 @@ def query_lot_associations(): # Batch mode for materials/rejects/holds batch_types = {'materials', 'rejects', 'holds'} - if container_ids_param and assoc_type in batch_types: - cids = [c.strip() for c in container_ids_param.split(',') if c.strip()] - if not cids: - return jsonify({'error': '請指定 CONTAINERID'}), 400 - result = get_lot_associations_batch(cids, assoc_type) - else: + if container_ids_param and assoc_type in batch_types: + cids = [c.strip() for c in container_ids_param.split(',') if c.strip()] + if not cids: + return jsonify({'error': '請指定 CONTAINERID'}), 400 + too_large = _reject_if_batch_too_large(cids) + if too_large is not None: + return too_large + result = get_lot_associations_batch(cids, assoc_type) + else: if not container_id: return jsonify({'error': '請指定 CONTAINERID'}), 400 @@ -355,7 +376,7 @@ def query_lot_associations(): @query_tool_bp.route('/api/query-tool/equipment-period', methods=['POST']) @_QUERY_TOOL_EQUIPMENT_RATE_LIMIT -def query_equipment_period(): +def query_equipment_period(): """Query equipment data for a time period. Expects JSON body: @@ -369,12 +390,11 @@ def query_equipment_period(): Returns data based on query_type. """ - data = request.get_json() - - if not data: - return jsonify({'error': '請求內容不可為空'}), 400 - - equipment_ids = data.get('equipment_ids', []) + data, payload_error = parse_json_payload(require_non_empty_object=True) + if payload_error is not None: + return jsonify({'error': payload_error.message}), payload_error.status_code + + equipment_ids = data.get('equipment_ids', []) equipment_names = data.get('equipment_names', []) start_date = data.get('start_date') end_date = data.get('end_date') @@ -497,7 +517,7 @@ def get_workcenter_groups_list(): @query_tool_bp.route('/api/query-tool/export-csv', methods=['POST']) @_QUERY_TOOL_EXPORT_RATE_LIMIT -def export_csv(): +def export_csv(): """Export query results as CSV. Expects JSON body: @@ -511,12 +531,11 @@ def export_csv(): Returns streaming CSV response. """ - data = request.get_json() - - if not data: - return jsonify({'error': '請求內容不可為空'}), 400 - - export_type = data.get('export_type') + data, payload_error = parse_json_payload(require_non_empty_object=True) + if payload_error is not None: + return jsonify({'error': payload_error.message}), payload_error.status_code + + export_type = data.get('export_type') params = data.get('params', {}) # Get data based on export type diff --git a/src/mes_dashboard/routes/resource_routes.py b/src/mes_dashboard/routes/resource_routes.py index 47d1295..30288b2 100644 --- a/src/mes_dashboard/routes/resource_routes.py +++ b/src/mes_dashboard/routes/resource_routes.py @@ -6,7 +6,7 @@ Contains Flask Blueprint for resource/equipment-related API endpoints. import math import logging -from flask import Blueprint, jsonify, request +from flask import Blueprint, jsonify, request, current_app from mes_dashboard.core.database import ( get_db_connection, @@ -15,6 +15,7 @@ from mes_dashboard.core.database import ( ) from mes_dashboard.core.cache import cache_get, cache_set, make_cache_key from mes_dashboard.core.rate_limit import configured_rate_limit +from mes_dashboard.core.request_validation import parse_json_payload from mes_dashboard.core.response import INTERNAL_ERROR, error_response from mes_dashboard.core.utils import get_days_back, parse_bool_query @@ -147,6 +148,14 @@ def _optional_bool_arg(name: str): return parse_bool_query(text) +def _config_int(name: str, default: int, minimum: int = 1) -> int: + try: + parsed = int(current_app.config.get(name, default)) + except Exception: + parsed = int(default) + return max(parsed, minimum) + + @resource_bp.route('/by_status') def api_resource_by_status(): """API: Resource count by status.""" @@ -205,10 +214,33 @@ def api_resource_workcenter_status_matrix(): @_RESOURCE_DETAIL_RATE_LIMIT def api_resource_detail(): """API: Resource detail with filters.""" - data = request.get_json() or {} + data, payload_error = parse_json_payload(require_object=True) + if payload_error is not None: + return jsonify({'success': False, 'error': payload_error.message}), payload_error.status_code + filters = data.get('filters') - limit = data.get('limit', 500) - offset = data.get('offset', 0) + if filters is not None and not isinstance(filters, dict): + return jsonify({'success': False, 'error': 'filters 必須為物件'}), 400 + + default_limit = _config_int("RESOURCE_DETAIL_DEFAULT_LIMIT", 500) + max_limit = _config_int("RESOURCE_DETAIL_MAX_LIMIT", default_limit) + + try: + limit = int(data.get('limit', default_limit)) + except (TypeError, ValueError): + return jsonify({'success': False, 'error': 'limit 必須為整數'}), 400 + if limit < 1: + return jsonify({'success': False, 'error': 'limit 必須大於 0'}), 400 + if limit > max_limit: + return jsonify({'success': False, 'error': f'limit 不可超過 {max_limit}'}), 413 + + try: + offset = int(data.get('offset', 0)) + except (TypeError, ValueError): + return jsonify({'success': False, 'error': 'offset 必須為整數'}), 400 + if offset < 0: + return jsonify({'success': False, 'error': 'offset 不可小於 0'}), 400 + days_back = get_days_back(filters) df = query_resource_detail(filters, limit, offset, days_back) diff --git a/src/mes_dashboard/routes/wip_routes.py b/src/mes_dashboard/routes/wip_routes.py index ea4e03c..92ae1ac 100644 --- a/src/mes_dashboard/routes/wip_routes.py +++ b/src/mes_dashboard/routes/wip_routes.py @@ -371,7 +371,10 @@ def api_meta_search(): """ search_field = request.args.get('field', '').strip().lower() q = request.args.get('q', '').strip() - limit = min(request.args.get('limit', 20, type=int), 50) + limit_value = request.args.get('limit', 20, type=int) + if limit_value is None: + limit_value = 20 + limit = min(max(limit_value, 1), 50) include_dummy = parse_bool_query(request.args.get('include_dummy')) # Cross-filter parameters diff --git a/src/mes_dashboard/services/page_registry.py b/src/mes_dashboard/services/page_registry.py index 9fb982c..95ddb8d 100644 --- a/src/mes_dashboard/services/page_registry.py +++ b/src/mes_dashboard/services/page_registry.py @@ -88,11 +88,11 @@ def _load() -> dict: logger.debug("Loaded page status from %s", DATA_FILE) except (json.JSONDecodeError, OSError) as e: logger.warning("Failed to load page status: %s", e) - _cache = {"pages": [], "api_public": True} + _cache = {"pages": [], "api_public": False} _cache_mtime = 0.0 else: logger.info("Page status file not found, using defaults") - _cache = {"pages": [], "api_public": True} + _cache = {"pages": [], "api_public": False} _cache_mtime = 0.0 if _migrate_navigation_schema(_cache): @@ -487,7 +487,14 @@ def is_api_public() -> bool: True if API endpoints bypass permission checks """ with _lock: - return _load().get("api_public", True) + value = _load().get("api_public", False) + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + if isinstance(value, (int, float)): + return bool(value) + return False def reload_cache() -> None: diff --git a/src/mes_dashboard/services/query_tool_service.py b/src/mes_dashboard/services/query_tool_service.py index 1a9a8db..c1af9f2 100644 --- a/src/mes_dashboard/services/query_tool_service.py +++ b/src/mes_dashboard/services/query_tool_service.py @@ -18,6 +18,7 @@ Architecture: import csv import io import logging +import os import re from datetime import datetime, timedelta from decimal import Decimal @@ -47,11 +48,18 @@ MAX_EQUIPMENTS = 20 MAX_DATE_RANGE_DAYS = 90 DEFAULT_TIME_WINDOW_HOURS = 168 # 1 week for better PJ_TYPE detection ADJACENT_LOTS_COUNT = 3 - - -# ============================================================ -# Validation Functions -# ============================================================ + + +def _max_batch_container_ids() -> int: + try: + return max(int(os.getenv("QUERY_TOOL_MAX_CONTAINER_IDS", "200")), 1) + except (TypeError, ValueError): + return 200 + + +# ============================================================ +# Validation Functions +# ============================================================ def validate_date_range(start_date: str, end_date: str, max_days: int = MAX_DATE_RANGE_DAYS) -> Optional[str]: """Validate date range. @@ -866,7 +874,7 @@ def get_adjacent_lots( # LOT Batch Query Functions # ============================================================ -def get_lot_history_batch( +def get_lot_history_batch( container_ids: List[str], workcenter_groups: Optional[List[str]] = None, ) -> Dict[str, Any]: @@ -878,11 +886,14 @@ def get_lot_history_batch( Returns: Dict with 'data' (merged history records) and 'total'. - """ - if not container_ids: - return {'error': '請指定 CONTAINERID'} - - try: + """ + if not container_ids: + return {'error': '請指定 CONTAINERID'} + max_ids = _max_batch_container_ids() + if len(container_ids) > max_ids: + return {'error': f'container_ids 數量不可超過 {max_ids} 筆'} + + try: events_by_cid = EventFetcher.fetch_events(container_ids, "history") rows = [] @@ -930,11 +941,14 @@ def get_lot_associations_batch( Returns: Dict with 'data' (merged records) and 'total'. - """ - if not container_ids: - return {'error': '請指定 CONTAINERID'} - - valid_batch_types = {'materials', 'rejects', 'holds'} + """ + if not container_ids: + return {'error': '請指定 CONTAINERID'} + max_ids = _max_batch_container_ids() + if len(container_ids) > max_ids: + return {'error': f'container_ids 數量不可超過 {max_ids} 筆'} + + valid_batch_types = {'materials', 'rejects', 'holds'} if assoc_type not in valid_batch_types: return {'error': f'批次查詢不支援類型: {assoc_type}'} diff --git a/src/mes_dashboard/services/resource_service.py b/src/mes_dashboard/services/resource_service.py index 7e8f728..4a8ca99 100644 --- a/src/mes_dashboard/services/resource_service.py +++ b/src/mes_dashboard/services/resource_service.py @@ -5,6 +5,7 @@ Provides functions to query equipment status from DWH.DW_MES_RESOURCE and DWH.DW """ import logging +import os import pandas as pd from typing import Optional, Dict, List, Any @@ -173,6 +174,22 @@ def query_resource_detail( DataFrame with resource details or None if query fails. """ try: + try: + max_limit = max(int(os.getenv("RESOURCE_DETAIL_MAX_LIMIT", "500")), 1) + except (TypeError, ValueError): + max_limit = 500 + try: + limit = int(limit) + except (TypeError, ValueError): + limit = 500 + try: + offset = int(offset) + except (TypeError, ValueError): + offset = 0 + + limit = max(1, min(limit, max_limit)) + offset = max(offset, 0) + base_sql = get_resource_latest_status_subquery(days_back) # Use QueryBuilder for safe parameterized conditions diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 77b8d9d..e421b90 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -130,6 +130,34 @@ class AppFactoryTests(unittest.TestCase): else: os.environ["PORTAL_SPA_ENABLED"] = old + def test_default_env_is_production_when_flask_env_missing(self): + old_flask_env = os.environ.pop("FLASK_ENV", None) + old_secret = os.environ.get("SECRET_KEY") + old_runtime_contract = os.environ.get("RUNTIME_CONTRACT_ENFORCE") + old_realtime_cache = os.environ.get("REALTIME_EQUIPMENT_CACHE_ENABLED") + try: + os.environ["SECRET_KEY"] = "test-production-secret-key" + os.environ["RUNTIME_CONTRACT_ENFORCE"] = "false" + os.environ["REALTIME_EQUIPMENT_CACHE_ENABLED"] = "false" + + app = create_app() + self.assertEqual(app.config.get("ENV"), "production") + finally: + if old_flask_env is not None: + os.environ["FLASK_ENV"] = old_flask_env + if old_secret is None: + os.environ.pop("SECRET_KEY", None) + else: + os.environ["SECRET_KEY"] = old_secret + if old_runtime_contract is None: + os.environ.pop("RUNTIME_CONTRACT_ENFORCE", None) + else: + os.environ["RUNTIME_CONTRACT_ENFORCE"] = old_runtime_contract + if old_realtime_cache is None: + os.environ.pop("REALTIME_EQUIPMENT_CACHE_ENABLED", None) + else: + os.environ["REALTIME_EQUIPMENT_CACHE_ENABLED"] = old_realtime_cache + if __name__ == "__main__": unittest.main() diff --git a/tests/test_hold_routes.py b/tests/test_hold_routes.py index 2236540..6ab5da1 100644 --- a/tests/test_hold_routes.py +++ b/tests/test_hold_routes.py @@ -297,8 +297,8 @@ class TestHoldDetailLotsRoute(TestHoldRoutesBase): call_args = mock_get_lots.call_args self.assertEqual(call_args.kwargs['page_size'], 200) - @patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots') - def test_handles_page_less_than_one(self, mock_get_lots): + @patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots') + def test_handles_page_less_than_one(self, mock_get_lots): """Page number less than 1 should be set to 1.""" mock_get_lots.return_value = { 'lots': [], @@ -308,8 +308,36 @@ class TestHoldDetailLotsRoute(TestHoldRoutesBase): response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&page=0') - call_args = mock_get_lots.call_args - self.assertEqual(call_args.kwargs['page'], 1) + call_args = mock_get_lots.call_args + self.assertEqual(call_args.kwargs['page'], 1) + + @patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots') + def test_handles_invalid_page_type(self, mock_get_lots): + mock_get_lots.return_value = { + 'lots': [], + 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1}, + 'filters': {'workcenter': None, 'package': None, 'ageRange': None} + } + + response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&page=abc') + self.assertEqual(response.status_code, 200) + + call_args = mock_get_lots.call_args + self.assertEqual(call_args.kwargs['page'], 1) + + @patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots') + def test_handles_invalid_per_page_type(self, mock_get_lots): + mock_get_lots.return_value = { + 'lots': [], + 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1}, + 'filters': {'workcenter': None, 'package': None, 'ageRange': None} + } + + response = self.client.get('/api/wip/hold-detail/lots?reason=YieldLimit&per_page=abc') + self.assertEqual(response.status_code, 200) + + call_args = mock_get_lots.call_args + self.assertEqual(call_args.kwargs['page_size'], 50) @patch('mes_dashboard.routes.hold_routes.get_hold_detail_lots') def test_returns_error_on_failure(self, mock_get_lots): diff --git a/tests/test_job_query_frontend_safety.py b/tests/test_job_query_frontend_safety.py new file mode 100644 index 0000000..94fed20 --- /dev/null +++ b/tests/test_job_query_frontend_safety.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""Frontend safety contract tests for job-query module rendering.""" + +from __future__ import annotations + +from pathlib import Path + + +def test_job_query_module_avoids_inline_onclick_string_interpolation(): + source = ( + Path(__file__).resolve().parents[1] + / "frontend" + / "src" + / "job-query" + / "main.js" + ).read_text(encoding="utf-8") + + assert "onclick=" not in source + assert 'data-action="toggle-equipment"' in source + assert 'data-action="toggle-job-history"' in source + assert "encodeURIComponent(safeText(value))" in source + assert "decodeURIComponent(value)" in source diff --git a/tests/test_job_query_routes.py b/tests/test_job_query_routes.py index 8143855..62c234f 100644 --- a/tests/test_job_query_routes.py +++ b/tests/test_job_query_routes.py @@ -87,8 +87,45 @@ class TestGetResources: assert 'ORA-01017' not in data['error'] -class TestQueryJobs: - """Tests for /api/job-query/jobs endpoint.""" +class TestQueryJobs: + """Tests for /api/job-query/jobs endpoint.""" + + @patch('mes_dashboard.routes.job_query_routes.get_jobs_by_resources') + def test_non_json_payload_returns_415(self, mock_query, client): + response = client.post( + '/api/job-query/jobs', + data='plain-text', + content_type='text/plain', + ) + assert response.status_code == 415 + payload = response.get_json() + assert 'error' in payload + mock_query.assert_not_called() + + @patch('mes_dashboard.routes.job_query_routes.get_jobs_by_resources') + def test_malformed_json_returns_400(self, mock_query, client): + response = client.post( + '/api/job-query/jobs', + data='{"resource_ids":', + content_type='application/json', + ) + assert response.status_code == 400 + payload = response.get_json() + assert 'error' in payload + mock_query.assert_not_called() + + @patch('mes_dashboard.routes.job_query_routes.get_jobs_by_resources') + def test_payload_too_large_returns_413(self, mock_query, client): + client.application.config['MAX_JSON_BODY_BYTES'] = 8 + response = client.post( + '/api/job-query/jobs', + data='{"resource_ids":["RES001"]}', + content_type='application/json', + ) + assert response.status_code == 413 + payload = response.get_json() + assert 'error' in payload + mock_query.assert_not_called() def test_missing_resource_ids(self, client): """Should return error without resource_ids.""" @@ -256,8 +293,32 @@ class TestQueryJobTxnHistory: assert 'error' in data -class TestExportJobs: - """Tests for /api/job-query/export endpoint.""" +class TestExportJobs: + """Tests for /api/job-query/export endpoint.""" + + @patch('mes_dashboard.routes.job_query_routes.export_jobs_with_history') + def test_non_json_payload_returns_415(self, mock_export, client): + response = client.post( + '/api/job-query/export', + data='plain-text', + content_type='text/plain', + ) + assert response.status_code == 415 + payload = response.get_json() + assert 'error' in payload + mock_export.assert_not_called() + + @patch('mes_dashboard.routes.job_query_routes.export_jobs_with_history') + def test_malformed_json_returns_400(self, mock_export, client): + response = client.post( + '/api/job-query/export', + data='{"resource_ids":', + content_type='application/json', + ) + assert response.status_code == 400 + payload = response.get_json() + assert 'error' in payload + mock_export.assert_not_called() def test_missing_resource_ids(self, client): """Should return error without resource_ids.""" diff --git a/tests/test_page_registry.py b/tests/test_page_registry.py index ab0744a..b088007 100644 --- a/tests/test_page_registry.py +++ b/tests/test_page_registry.py @@ -196,6 +196,22 @@ class TestIsApiPublic: assert page_registry.is_api_public() is False + def test_api_public_defaults_false_when_key_missing(self, mock_registry, temp_data_file): + data = json.loads(temp_data_file.read_text()) + data.pop("api_public", None) + temp_data_file.write_text(json.dumps(data)) + page_registry._cache = None + + assert page_registry.is_api_public() is False + + def test_api_public_invalid_value_defaults_false(self, mock_registry, temp_data_file): + data = json.loads(temp_data_file.read_text()) + data["api_public"] = "not-a-bool" + temp_data_file.write_text(json.dumps(data)) + page_registry._cache = None + + assert page_registry.is_api_public() is False + class TestReloadCache: """Tests for reload_cache function.""" diff --git a/tests/test_query_tool_routes.py b/tests/test_query_tool_routes.py index 8ac4f99..b6ed930 100644 --- a/tests/test_query_tool_routes.py +++ b/tests/test_query_tool_routes.py @@ -53,8 +53,32 @@ class TestQueryToolPage: assert b'html' in response.data.lower() -class TestResolveEndpoint: - """Tests for /api/query-tool/resolve endpoint.""" +class TestResolveEndpoint: + """Tests for /api/query-tool/resolve endpoint.""" + + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + def test_non_json_payload_returns_415(self, mock_resolve, client): + response = client.post( + '/api/query-tool/resolve', + data='plain-text', + content_type='text/plain', + ) + assert response.status_code == 415 + payload = response.get_json() + assert 'error' in payload + mock_resolve.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.resolve_lots') + def test_malformed_json_returns_400(self, mock_resolve, client): + response = client.post( + '/api/query-tool/resolve', + data='{"input_type":', + content_type='application/json', + ) + assert response.status_code == 400 + payload = response.get_json() + assert 'error' in payload + mock_resolve.assert_not_called() def test_missing_input_type(self, client): """Should return error without input_type.""" @@ -238,7 +262,7 @@ class TestResolveEndpoint: assert mock_cache_set.call_args.kwargs['ttl'] == 60 -class TestLotHistoryEndpoint: +class TestLotHistoryEndpoint: """Tests for /api/query-tool/lot-history endpoint.""" def test_missing_container_id(self, client): @@ -270,15 +294,24 @@ class TestLotHistoryEndpoint: assert 'data' in data assert data['total'] == 1 - @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') - def test_lot_history_service_error(self, mock_query, client): - """Should return error from service.""" - mock_query.return_value = {'error': '查詢失敗'} + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + def test_lot_history_service_error(self, mock_query, client): + """Should return error from service.""" + mock_query.return_value = {'error': '查詢失敗'} - response = client.get('/api/query-tool/lot-history?container_id=invalid') - assert response.status_code == 400 - data = json.loads(response.data) - assert 'error' in data + response = client.get('/api/query-tool/lot-history?container_id=invalid') + assert response.status_code == 400 + data = json.loads(response.data) + assert 'error' in data + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history_batch') + def test_lot_history_batch_over_limit_returns_413(self, mock_batch, client): + client.application.config['QUERY_TOOL_MAX_CONTAINER_IDS'] = 2 + response = client.get('/api/query-tool/lot-history?container_ids=A,B,C') + assert response.status_code == 413 + payload = response.get_json() + assert 'error' in payload + mock_batch.assert_not_called() class TestAdjacentLotsEndpoint: @@ -425,6 +458,17 @@ class TestLotAssociationsEndpoint: assert response.status_code == 200 mock_query.assert_called_once_with('488103800029578b', full_history=True) + @patch('mes_dashboard.routes.query_tool_routes.get_lot_associations_batch') + def test_lot_associations_batch_over_limit_returns_413(self, mock_batch, client): + client.application.config['QUERY_TOOL_MAX_CONTAINER_IDS'] = 1 + response = client.get( + '/api/query-tool/lot-associations?type=materials&container_ids=A,B' + ) + assert response.status_code == 413 + payload = response.get_json() + assert 'error' in payload + mock_batch.assert_not_called() + class TestQueryToolRateLimit: """Rate-limit behavior for high-cost query-tool endpoints.""" @@ -532,8 +576,32 @@ class TestQueryToolRateLimit: mock_history.assert_not_called() -class TestEquipmentPeriodEndpoint: - """Tests for /api/query-tool/equipment-period endpoint.""" +class TestEquipmentPeriodEndpoint: + """Tests for /api/query-tool/equipment-period endpoint.""" + + @patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours') + def test_non_json_payload_returns_415(self, mock_query, client): + response = client.post( + '/api/query-tool/equipment-period', + data='plain-text', + content_type='text/plain', + ) + assert response.status_code == 415 + payload = response.get_json() + assert 'error' in payload + mock_query.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_equipment_status_hours') + def test_malformed_json_returns_400(self, mock_query, client): + response = client.post( + '/api/query-tool/equipment-period', + data='{"equipment_ids":', + content_type='application/json', + ) + assert response.status_code == 400 + payload = response.get_json() + assert 'error' in payload + mock_query.assert_not_called() def test_missing_query_type(self, client): """Should return error without query_type.""" @@ -660,8 +728,32 @@ class TestEquipmentPeriodEndpoint: assert 'data' in data -class TestExportCsvEndpoint: - """Tests for /api/query-tool/export-csv endpoint.""" +class TestExportCsvEndpoint: + """Tests for /api/query-tool/export-csv endpoint.""" + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + def test_non_json_payload_returns_415(self, mock_get_history, client): + response = client.post( + '/api/query-tool/export-csv', + data='plain-text', + content_type='text/plain', + ) + assert response.status_code == 415 + payload = response.get_json() + assert 'error' in payload + mock_get_history.assert_not_called() + + @patch('mes_dashboard.routes.query_tool_routes.get_lot_history') + def test_malformed_json_returns_400(self, mock_get_history, client): + response = client.post( + '/api/query-tool/export-csv', + data='{"export_type":', + content_type='application/json', + ) + assert response.status_code == 400 + payload = response.get_json() + assert 'error' in payload + mock_get_history.assert_not_called() def test_missing_export_type(self, client): """Should return error without export_type.""" diff --git a/tests/test_rate_limit_identity.py b/tests/test_rate_limit_identity.py new file mode 100644 index 0000000..b405a78 --- /dev/null +++ b/tests/test_rate_limit_identity.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +"""Tests for rate-limit client identity trust boundary behavior.""" + +from flask import Flask + +from mes_dashboard.core.rate_limit import _client_identifier + + +def _app() -> Flask: + return Flask(__name__) + + +def test_client_identifier_ignores_xff_when_proxy_trust_disabled(monkeypatch): + monkeypatch.setenv("TRUST_PROXY_HEADERS", "false") + monkeypatch.delenv("TRUSTED_PROXY_IPS", raising=False) + + app = _app() + with app.test_request_context( + "/", + headers={"X-Forwarded-For": "1.2.3.4"}, + environ_base={"REMOTE_ADDR": "9.9.9.9"}, + ): + assert _client_identifier() == "9.9.9.9" + + +def test_client_identifier_uses_xff_for_trusted_proxy_source(monkeypatch): + monkeypatch.setenv("TRUST_PROXY_HEADERS", "true") + monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1") + + app = _app() + with app.test_request_context( + "/", + headers={"X-Forwarded-For": "1.2.3.4, 5.6.7.8"}, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ): + assert _client_identifier() == "1.2.3.4" + + +def test_client_identifier_rejects_untrusted_proxy_source(monkeypatch): + monkeypatch.setenv("TRUST_PROXY_HEADERS", "true") + monkeypatch.setenv("TRUSTED_PROXY_IPS", "127.0.0.1") + + app = _app() + with app.test_request_context( + "/", + headers={"X-Forwarded-For": "1.2.3.4"}, + environ_base={"REMOTE_ADDR": "10.10.10.10"}, + ): + assert _client_identifier() == "10.10.10.10" + + +def test_client_identifier_requires_allowlist_when_proxy_trust_enabled(monkeypatch): + monkeypatch.setenv("TRUST_PROXY_HEADERS", "true") + monkeypatch.delenv("TRUSTED_PROXY_IPS", raising=False) + + app = _app() + with app.test_request_context( + "/", + headers={"X-Forwarded-For": "1.2.3.4"}, + environ_base={"REMOTE_ADDR": "127.0.0.1"}, + ): + assert _client_identifier() == "127.0.0.1" diff --git a/tests/test_redis_client.py b/tests/test_redis_client.py index 82520a9..a43115c 100644 --- a/tests/test_redis_client.py +++ b/tests/test_redis_client.py @@ -83,13 +83,41 @@ class TestRedisClient: key = rc.get_key('mykey') assert key == 'test_prefix:mykey' - def test_get_key_without_prefix(self): - """Test get_key works with empty prefix.""" - import mes_dashboard.core.redis_client as rc - - with patch.object(rc, 'REDIS_KEY_PREFIX', ''): - key = rc.get_key('mykey') - assert key == ':mykey' + def test_get_key_without_prefix(self): + """Test get_key works with empty prefix.""" + import mes_dashboard.core.redis_client as rc + + with patch.object(rc, 'REDIS_KEY_PREFIX', ''): + key = rc.get_key('mykey') + assert key == ':mykey' + + def test_redact_connection_url_masks_password(self): + import mes_dashboard.core.redis_client as rc + + redacted = rc.redact_connection_url("redis://user:secret@localhost:6379/0") + assert redacted == "redis://user:***@localhost:6379/0" + + def test_redact_connection_url_without_credentials(self): + import mes_dashboard.core.redis_client as rc + + redacted = rc.redact_connection_url("redis://localhost:6379/0") + assert redacted == "redis://localhost:6379/0" + + def test_get_redis_client_logs_redacted_url(self, reset_module): + import mes_dashboard.core.redis_client as rc + + with patch.object(rc, 'REDIS_ENABLED', True): + with patch.object(rc, 'REDIS_URL', 'redis://user:secret@localhost:6379/0'): + with patch.object(rc.redis.Redis, 'from_url') as mock_from_url: + with patch.object(rc.logger, 'info') as mock_info: + mock_client = MagicMock() + mock_client.ping.return_value = True + mock_from_url.return_value = mock_client + + rc.get_redis_client() + + logged_url = mock_info.call_args.args[1] + assert logged_url == 'redis://user:***@localhost:6379/0' class TestRedisClientSingleton: diff --git a/tests/test_resource_routes.py b/tests/test_resource_routes.py index 311b848..1b68703 100644 --- a/tests/test_resource_routes.py +++ b/tests/test_resource_routes.py @@ -72,3 +72,77 @@ def test_resource_status_masks_internal_error_details(_mock_status): assert payload["error"]["code"] == "INTERNAL_ERROR" assert payload["error"]["message"] == "服務暫時無法使用" assert "sensitive sql context" not in str(payload) + + +@patch("mes_dashboard.routes.resource_routes.query_resource_detail") +def test_resource_detail_non_json_payload_returns_415(mock_query): + response = _client().post( + "/api/resource/detail", + data="plain-text", + content_type="text/plain", + ) + + assert response.status_code == 415 + payload = response.get_json() + assert payload["success"] is False + assert "error" in payload + mock_query.assert_not_called() + + +@patch("mes_dashboard.routes.resource_routes.query_resource_detail") +def test_resource_detail_malformed_json_returns_400(mock_query): + response = _client().post( + "/api/resource/detail", + data='{"filters":', + content_type="application/json", + ) + + assert response.status_code == 400 + payload = response.get_json() + assert payload["success"] is False + assert "error" in payload + mock_query.assert_not_called() + + +@patch("mes_dashboard.routes.resource_routes.query_resource_detail") +def test_resource_detail_rejects_limit_over_configured_max(mock_query): + client = _client() + client.application.config["RESOURCE_DETAIL_MAX_LIMIT"] = 100 + response = client.post( + "/api/resource/detail", + json={"limit": 101, "offset": 0, "filters": {}}, + ) + + assert response.status_code == 413 + payload = response.get_json() + assert payload["success"] is False + assert "limit" in payload["error"] + mock_query.assert_not_called() + + +@patch("mes_dashboard.routes.resource_routes.query_resource_detail") +def test_resource_detail_rejects_invalid_limit_type(mock_query): + response = _client().post( + "/api/resource/detail", + json={"limit": "abc", "offset": 0, "filters": {}}, + ) + + assert response.status_code == 400 + payload = response.get_json() + assert payload["success"] is False + assert "limit" in payload["error"] + mock_query.assert_not_called() + + +@patch("mes_dashboard.routes.resource_routes.query_resource_detail") +def test_resource_detail_rejects_negative_offset(mock_query): + response = _client().post( + "/api/resource/detail", + json={"limit": 10, "offset": -1, "filters": {}}, + ) + + assert response.status_code == 400 + payload = response.get_json() + assert payload["success"] is False + assert "offset" in payload["error"] + mock_query.assert_not_called() diff --git a/tests/test_runtime_hardening.py b/tests/test_runtime_hardening.py index a6e0a22..96b76ef 100644 --- a/tests/test_runtime_hardening.py +++ b/tests/test_runtime_hardening.py @@ -159,6 +159,7 @@ def test_security_headers_applied_globally(testing_app_factory): assert response.status_code == 200 assert "Content-Security-Policy" in response.headers assert "frame-ancestors 'self'" in response.headers["Content-Security-Policy"] + assert "'unsafe-eval'" not in response.headers["Content-Security-Policy"] assert response.headers["X-Frame-Options"] == "SAMEORIGIN" assert response.headers["X-Content-Type-Options"] == "nosniff" assert "Referrer-Policy" in response.headers @@ -181,3 +182,32 @@ def test_hsts_header_enabled_in_production(monkeypatch): assert "Strict-Transport-Security" in response.headers _shutdown(app) + + +def test_csp_unsafe_eval_can_be_enabled_via_env(monkeypatch): + monkeypatch.setenv("CSP_ALLOW_UNSAFE_EVAL", "true") + # Build app directly to control env behavior. + monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false") + db._ENGINE = None + db._HEALTH_ENGINE = None + app = create_app("testing") + app.config["TESTING"] = True + + response = app.test_client().get("/", follow_redirects=True) + assert response.status_code == 200 + assert "'unsafe-eval'" in response.headers["Content-Security-Policy"] + + _shutdown(app) + + +def test_production_trusted_proxy_requires_allowlist(monkeypatch): + monkeypatch.setenv("SECRET_KEY", "test-production-secret-key") + monkeypatch.setenv("REALTIME_EQUIPMENT_CACHE_ENABLED", "false") + monkeypatch.setenv("RUNTIME_CONTRACT_ENFORCE", "false") + monkeypatch.setenv("TRUST_PROXY_HEADERS", "true") + monkeypatch.delenv("TRUSTED_PROXY_IPS", raising=False) + db._ENGINE = None + db._HEALTH_ENGINE = None + + with pytest.raises(RuntimeError, match="TRUSTED_PROXY_IPS"): + create_app("production") diff --git a/tests/test_wip_routes.py b/tests/test_wip_routes.py index 26e523d..af4fa91 100644 --- a/tests/test_wip_routes.py +++ b/tests/test_wip_routes.py @@ -535,6 +535,32 @@ class TestMetaFilterOptionsRoute(TestWipRoutesBase): self.assertFalse(data['success']) +class TestMetaSearchRoute(TestWipRoutesBase): + """Test GET /api/wip/meta/search endpoint.""" + + @patch('mes_dashboard.routes.wip_routes.search_workorders') + def test_invalid_limit_type_falls_back_to_default(self, mock_search): + mock_search.return_value = [] + + response = self.client.get('/api/wip/meta/search?field=workorder&q=WO&limit=abc') + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertEqual(mock_search.call_args.kwargs['limit'], 20) + + @patch('mes_dashboard.routes.wip_routes.search_workorders') + def test_limit_is_bounded_with_upper_cap(self, mock_search): + mock_search.return_value = [] + + response = self.client.get('/api/wip/meta/search?field=workorder&q=WO&limit=999') + data = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(data['success']) + self.assertEqual(mock_search.call_args.kwargs['limit'], 50) + + class TestPageRoutes(TestWipRoutesBase): """Test page routes for WIP dashboards."""