"""Regression test for the buyback-amount tamper-bypass attack (security pass 1, 2026-05-12). Attack model: a malicious employee (or any compromised client / LLM agent / direct curl) submits the offboarding-buyback form with a forged Amount answer: POST /api/v1/approval/asset/requests { "template_key": "offboarding_buyback", "asset_id": "", "answers": {"Amount": {"amount": "1", "currency": "CNY"}} } Before the fix, the value shipped verbatim to Feishu (the approver in Feishu sees "1 CNY" — possibly rubber-stamps it), the callback extracted ``buyback_amount=1`` from the Feishu form, and ``dispose_asset(disposal_type="sold")`` recorded the tampered amount in audit and disposed the asset for 1 CNY. The fix (``_enforce_server_buyback_amount`` in approval/service.py) overwrites the client-supplied Amount with the canonical server quote BEFORE serialising to Feishu. This regression test confirms that the form payload Feishu receives carries the SERVER amount, not the attacker's. """ import json from datetime import UTC, datetime from decimal import Decimal from unittest.mock import AsyncMock, patch import pytest from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.asset.models import AssetEmployeeRequest, AssetItem @pytest.fixture(autouse=True) def _approval_idempotency_headers(request): for fixture_name in ("employee_headers", "admin_headers"): if fixture_name in request.fixturenames: request.getfixturevalue(fixture_name)["Idempotency-Key"] = ( f"test-{request.node.name}" ) _TEST_FEISHU_VERIFICATION_TOKEN = "test-feishu-token" _FAKE_FEISHU_CFG = { "app_id": "cli_test", "app_secret": "secret_test", "event_verification_token": _TEST_FEISHU_VERIFICATION_TOKEN, } _FAKE_APPROVAL_FLOWS = { "laptop_replacement": "7694D6BE-7FEE-4E49-96B5-A106FC4623DD", "computer_borrow": "8D3A735C-B91C-4EF0-9476-6517EC31B110", "asset_return": "E4ED0075-D2D2-479F-85F7-11374B0CC626", "offboarding_buyback": "3B6AAF34-4762-4A24-AE3A-8E035B7BEDA8", } _OFFBOARDING_BUYBACK_FORM = [ { "id": "resign_date", "name": "Resignation Date", "type": "date", "required": True, }, { "id": "serial", "name": "Buyback Device Serial Number", "type": "input", "required": True, }, { "id": "amount", "name": "Amount", "type": "amount", "required": True, # Mirror the prod offboarding_buyback "Repurchase price" widget: # a multi-currency amount control. Feishu labels a bare-number # form value with currencyRange[0] (CNY); a non-default currency # must ride along in the form value. See _serialize_widget_value. "option": { "currencyRange": ["CNY", "USD", "JPY", "SGD", "MYR"], "isCapital": False, }, }, ] @pytest.fixture(autouse=True) def _patch_feishu_config(monkeypatch): async def _fake_cfg(**_kwargs): return dict(_FAKE_FEISHU_CFG) async def _fake_flows(**_kwargs): return dict(_FAKE_APPROVAL_FLOWS) monkeypatch.setattr("app.integrations.compat.get_feishu_config", _fake_cfg) monkeypatch.setattr("app.approval.service.get_feishu_config", _fake_cfg) monkeypatch.setattr( "app.approval.router.get_feishu_config", _fake_cfg, raising=False, ) monkeypatch.setattr( "app.integrations.service.get_approval_flows_map", _fake_flows, ) async def _fake_token(self): return "fake-tenant-token" monkeypatch.setattr( "app.approval.feishu_client.FeishuApprovalClient.get_tenant_access_token", _fake_token, ) @pytest.mark.asyncio async def test_server_overrides_tampered_buyback_amount_on_submit( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """Attack: employee POSTs ``answers["amount"]={amount:"1",currency:"CNY"}``. Defence: server re-quotes (policy → CNY 400 for a 5 000 CNY laptop > 36 months old) and overwrites BEFORE the form ships to Feishu. Asserts the FORM payload sent to ``create_instance`` carries the server amount, NOT the attacker's. Also asserts the ``employee_requests`` row's resolved_answers record the server value (audit trail). """ employee, _ = employee_user asset = AssetItem( asset_tag="BB-TAMPER-1", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", # Policy: ≥ 36 mo → tier 1 floor = CNY 400 purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured_form: dict[str, str] = {} async def _capture_create_instance( self, *, approval_code, user_id, form, instance_uuid=None ): captured_form["payload"] = form return { "instance_code": "feishu_tamper_test_001", "instance_status": "PENDING", } with ( patch( "app.approval.service.FeishuApprovalClient.get_approval_definition", new=AsyncMock( return_value={ "approval_name": "Laptop Purchase Request While Offboarding", "form": _OFFBOARDING_BUYBACK_FORM, } ), ), patch( "app.approval.service.FeishuApprovalClient.resolve_user_id_by_email", new=AsyncMock(return_value="feishu_user_tamper"), ), patch( "app.approval.service.FeishuApprovalClient.create_instance", new=_capture_create_instance, ), ): response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "offboarding_buyback", "asset_id": str(asset.id), "reason": "Buying back my laptop", "answers": { "resign_date": "2026-06-01", "serial": "TAMPER-SN-001", # The attack — forged amount. "amount": {"amount": "1", "currency": "CNY"}, }, }, ) assert response.status_code == 200, response.text # Server re-quoted: the form Feishu received must carry CNY 400, # NOT the attacker's "1". import json feishu_form = json.loads(captured_form["payload"]) amount_entries = [w for w in feishu_form if w.get("id") == "amount"] assert len(amount_entries) == 1, feishu_form # Feishu's 金额 widget takes a PLAIN numeric string (not a stringified # {"amount","currency"} dict — that renders blank in editable approval # nodes). Currency lives in resolved_answers / audit, not the form # value. See _serialize_widget_value. amount_form_value = amount_entries[0]["value"] assert Decimal(amount_form_value) == Decimal("400.00"), ( f"server failed to overwrite tampered amount; Feishu would have " f"received {amount_form_value!r}" ) # DB row also reflects the canonical amount in resolved_answers # (the audit trail). submitted_answers preserves the attack # payload so an investigator can see the tamper attempt. request_row = await db_session.execute( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id ) ) employee_request = request_row.scalar_one() resolved = employee_request.detail["resolved_answers"]["amount"] assert Decimal(resolved["amount"]) == Decimal("400.00") assert resolved["currency"] == "CNY" submitted = employee_request.detail["submitted_answers"]["amount"] assert submitted == {"amount": "1", "currency": "CNY"}, submitted @pytest.mark.asyncio async def test_server_overrides_name_keyed_sibling_bypass_attempt( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """Sibling-bypass attack (security review round 2, 2026-05-12): submit BOTH the id-keyed ``"amount"`` AND the name-keyed ``"Amount"`` so a single-key overwrite leaves the name-keyed attacker value intact; the serialiser's name→id resolution then clobbers the server's canonical amount with the attacker's value in insertion order. Fix: enforcement strips ALL Amount aliases (id, name, case variants) before writing the canonical entry, and the serialiser now gives id-keyed precedence as a second line of defence.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-TAMPER-SIBLING", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured_form: dict[str, str] = {} async def _capture_create_instance( self, *, approval_code, user_id, form, instance_uuid=None ): captured_form["payload"] = form return { "instance_code": "feishu_tamper_test_sibling", "instance_status": "PENDING", } with ( patch( "app.approval.service.FeishuApprovalClient.get_approval_definition", new=AsyncMock( return_value={ "approval_name": "Laptop Purchase Request While Offboarding", "form": _OFFBOARDING_BUYBACK_FORM, } ), ), patch( "app.approval.service.FeishuApprovalClient.resolve_user_id_by_email", new=AsyncMock(return_value="feishu_user_sibling"), ), patch( "app.approval.service.FeishuApprovalClient.create_instance", new=_capture_create_instance, ), ): response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "offboarding_buyback", "asset_id": str(asset.id), "reason": "Buying back my laptop", # The sibling attack: planting BOTH variants. Without # the round-2 fix, the name-keyed ``"Amount"`` would # clobber the id-keyed canonical value in serialise. "answers": { "resign_date": "2026-06-01", "serial": "TAMPER-SN-SIBLING", "amount": {"amount": "1", "currency": "CNY"}, "Amount": {"amount": "1", "currency": "CNY"}, }, }, ) assert response.status_code == 200, response.text import json feishu_form = json.loads(captured_form["payload"]) amount_entries = [w for w in feishu_form if w.get("id") == "amount"] # Exactly ONE amount entry must reach Feishu — duplicates would # confuse the approval UI and could be exploited later. assert len(amount_entries) == 1, feishu_form amount_form_value = amount_entries[0]["value"] assert Decimal(amount_form_value) == Decimal("400.00"), ( f"sibling-bypass succeeded: Feishu received {amount_form_value!r} " "instead of the server-canonical 400.00 CNY" ) @pytest.mark.asyncio async def test_server_overrides_uppercase_name_sibling_bypass( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """Variant of the sibling bypass using ``"AMOUNT"`` (all-caps) — the enforcement strips name aliases case-insensitively, so even though the serialiser's ``name_to_id`` is case-sensitive and wouldn't have resolved this anyway, we close the door pre-emptively in case name resolution ever becomes fuzzy.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-TAMPER-UPPER", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured_form: dict[str, str] = {} async def _capture_create_instance( self, *, approval_code, user_id, form, instance_uuid=None ): captured_form["payload"] = form return { "instance_code": "feishu_tamper_test_upper", "instance_status": "PENDING", } with ( patch( "app.approval.service.FeishuApprovalClient.get_approval_definition", new=AsyncMock( return_value={ "approval_name": "Laptop Purchase Request While Offboarding", "form": _OFFBOARDING_BUYBACK_FORM, } ), ), patch( "app.approval.service.FeishuApprovalClient.resolve_user_id_by_email", new=AsyncMock(return_value="feishu_user_upper"), ), patch( "app.approval.service.FeishuApprovalClient.create_instance", new=_capture_create_instance, ), ): response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "offboarding_buyback", "asset_id": str(asset.id), "reason": "Buying back my laptop", "answers": { "resign_date": "2026-06-01", "serial": "TAMPER-SN-UPPER", "AMOUNT": {"amount": "1", "currency": "CNY"}, }, }, ) assert response.status_code == 200, response.text import json feishu_form = json.loads(captured_form["payload"]) amount_entries = [w for w in feishu_form if w.get("id") == "amount"] assert len(amount_entries) == 1, feishu_form assert Decimal(amount_entries[0]["value"]) == Decimal("400.00") @pytest.mark.asyncio async def test_offboarding_buyback_without_asset_id_is_rejected( client: AsyncClient, employee_headers: dict, ): """Server enforces ``asset_id`` for offboarding_buyback so a client cannot skip the amount-enforcement path by submitting with no asset link. Frontend already requires this; the server gate is the matching defence.""" response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "offboarding_buyback", "asset_id": None, "reason": "Buying back my laptop", "answers": { "resign_date": "2026-06-01", "serial": "NO-ASSET-SN", "amount": {"amount": "1", "currency": "CNY"}, }, }, ) assert response.status_code == 400 assert ( response.json()["errors"][0]["code"] == "OFFBOARDING_BUYBACK_REQUIRES_ASSET" ) @pytest.mark.asyncio async def test_server_honors_currency_choice_but_overrides_amount( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """The employee CAN pick a different currency (USD/JPY/SGD), but the AMOUNT in that currency is still policy-driven. Attack variant: submit with `{amount: "999", currency: "USD"}` — server must keep USD but force amount to the policy floor (USD 55 for tier 1). The chosen currency is recorded in Helix's ``resolved_answers`` (audit) AND, when it differs from the widget's default currency (``currencyRange[0]``), rides along as a sibling ``currency`` key on the Feishu form item so the approval card is labelled with the right currency instead of falling back to CNY. See _amount_wire_currency.""" employee, _ = employee_user asset = AssetItem( asset_tag="BB-TAMPER-2", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured_form: dict[str, str] = {} async def _capture_create_instance( self, *, approval_code, user_id, form, instance_uuid=None ): captured_form["payload"] = form return { "instance_code": "feishu_tamper_test_002", "instance_status": "PENDING", } with ( patch( "app.approval.service.FeishuApprovalClient.get_approval_definition", new=AsyncMock( return_value={ "approval_name": "Laptop Purchase Request While Offboarding", "form": _OFFBOARDING_BUYBACK_FORM, } ), ), patch( "app.approval.service.FeishuApprovalClient.resolve_user_id_by_email", new=AsyncMock(return_value="feishu_user_tamper"), ), patch( "app.approval.service.FeishuApprovalClient.create_instance", new=_capture_create_instance, ), ): response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "offboarding_buyback", "asset_id": str(asset.id), "reason": "Buying back my laptop", "answers": { "resign_date": "2026-06-01", "serial": "TAMPER-SN-002", "amount": {"amount": "999", "currency": "USD"}, }, }, ) assert response.status_code == 200, response.text import json feishu_form = json.loads(captured_form["payload"]) amount_entries = [w for w in feishu_form if w.get("id") == "amount"] assert len(amount_entries) == 1, feishu_form # USD is a non-default currency the widget offers (currencyRange # ``["CNY","USD","JPY","SGD","MYR"]``), so the form ITEM carries a # sibling ``currency`` key and Feishu labels the card "USD" instead # of the currencyRange[0] default (CNY). ``value`` stays a plain # numeric string — an object there is the shape Feishu rejects with # 1395006. The amount is still forced to the policy floor (USD 55 # for tier 1). See _amount_wire_currency. amount_entry = amount_entries[0] assert not isinstance(amount_entry["value"], (dict, list)), amount_entry assert amount_entry["currency"] == "USD" assert Decimal(amount_entry["value"]) == Decimal("55.00") # The employee's currency choice is honoured + recorded in Helix's # resolved_answers (audit trail), with the amount forced to policy. request_row = await db_session.execute( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id ) ) employee_request = request_row.scalar_one() resolved = employee_request.detail["resolved_answers"]["amount"] assert resolved["currency"] == "USD" assert Decimal(resolved["amount"]) == Decimal("55.00") @pytest.mark.asyncio async def test_server_quote_applied_when_client_omits_amount_key( client: AsyncClient, employee_user, employee_headers: dict, db_session: AsyncSession, ): """Realistic frontend shape after the 2026-05-25 ``buildSubmissionAnswers`` fix: the Amount widget renders as a presentational Input + Select OUTSIDE a registered ``Form.Item``, so an in-flight ``buybackQuote=null`` state (asset-quote fetch failed / not yet returned / user clicked submit before the quote arrived) means the client's submitted ``answers`` dict carries NO ``"amount"`` key at all — even though the widget is marked ``required: True`` in the Feishu definition. Pre-fix the existing tamper-guard tests all PLANT an explicit amount key (mirroring the attack shape), so the omit path was never exercised. ``_enforce_server_buyback_amount`` MUST still inject the canonical amount + entity-default currency so the Feishu approval card shows a real number, not "缺失字段"/blank. """ employee, _ = employee_user asset = AssetItem( asset_tag="BB-OMIT-1", category="laptop", status="assigned", assigned_to=employee.id, purchase_cost=Decimal("5000"), currency="CNY", # ≥ 36 mo → tier 1 floor = CNY 400 (matches the existing # tamper-guard test asset shape for parity). purchase_date=datetime(2020, 1, 1, tzinfo=UTC), ) db_session.add(asset) await db_session.commit() captured_form: dict[str, str] = {} async def _capture_create_instance( self, *, approval_code, user_id, form, instance_uuid=None ): captured_form["payload"] = form return { "instance_code": "feishu_omit_test_001", "instance_status": "PENDING", } with ( patch( "app.approval.service.FeishuApprovalClient.get_approval_definition", new=AsyncMock( return_value={ "approval_name": "Laptop Purchase Request While Offboarding", "form": _OFFBOARDING_BUYBACK_FORM, } ), ), patch( "app.approval.service.FeishuApprovalClient.resolve_user_id_by_email", new=AsyncMock(return_value="feishu_user_omit"), ), patch( "app.approval.service.FeishuApprovalClient.create_instance", new=_capture_create_instance, ), ): response = await client.post( "/api/v1/approval/asset/requests", headers=employee_headers, json={ "template_key": "offboarding_buyback", "asset_id": str(asset.id), "reason": "Buying back my laptop", # No amount key — mimics frontend with buybackQuote=null # silently dropping the widget from the payload. "answers": { "resign_date": "2026-06-01", "serial": "OMIT-SN-001", }, }, ) assert response.status_code == 200, response.text import json feishu_form = json.loads(captured_form["payload"]) amount_entries = [w for w in feishu_form if w.get("id") == "amount"] # Server MUST inject the canonical amount even though the client # omitted it — otherwise the Feishu approval card would render # an empty Amount field and approvers would have to chase the # employee for the number. assert len(amount_entries) == 1, feishu_form amount_form_value = amount_entries[0]["value"] assert Decimal(amount_form_value) == Decimal("400.00"), ( f"omit-path failed: server did not inject canonical amount; " f"Feishu received {amount_form_value!r}" ) # DB row's resolved_answers reflects the canonical server value # (audit trail). submitted_answers is the literal client shape # — no amount key. request_row = await db_session.execute( select(AssetEmployeeRequest).where( AssetEmployeeRequest.asset_id == asset.id ) ) employee_request = request_row.scalar_one() resolved = employee_request.detail["resolved_answers"]["amount"] assert Decimal(resolved["amount"]) == Decimal("400.00") assert resolved["currency"] == "CNY" submitted = employee_request.detail["submitted_answers"] assert "amount" not in submitted, submitted # ── Unit contract for the amount-widget wire encoding ──────────────── # The multi-currency 金额 widget labels a bare-number form value with # ``currencyRange[0]`` (CNY). A non-default currency the widget offers # rides as a SIBLING ``currency`` key on the form item — NEVER inside # ``value``. Verified against live Feishu on a throwaway approval # definition (2026-08-13): ``{"value": {"amount":…,"currency":…}}`` is # rejected with ``1395006 控件值不合法或者为空`` (the bug that killed # every non-CNY 代提回购 after #2558), an ``ext`` key is accepted but # silently ignored, and only ``{"value": "816.61", "currency": "USD"}`` # lands ``ext.currency = USD`` on the created instance. See # ApprovalService._amount_wire_currency. _BUYBACK_RANGE = ["CNY", "USD", "JPY", "SGD", "MYR"] @pytest.mark.parametrize( "answer, currency_range, expected_value, expected_currency", [ # Default currency (CNY == currencyRange[0]) → bare number and # NO currency key: byte-for-byte the shape every shipped buyback # has used, zero regression risk. ({"amount": "500", "currency": "CNY"}, _BUYBACK_RANGE, "500", None), # Non-default, in-range → sibling currency key so the card reads JPY. ({"amount": "10400", "currency": "JPY"}, _BUYBACK_RANGE, "10400", "JPY"), # Case-insensitive — the USD/USA 代提回购 that reported the bug. ({"amount": "816.61", "currency": "usd"}, _BUYBACK_RANGE, "816.61", "USD"), # Currency outside the widget's currencyRange (CAD is not listed) # → bare number, no currency key: never feed Feishu an unknown # currency (it 400s the whole submission). ({"amount": "75", "currency": "CAD"}, _BUYBACK_RANGE, "75", None), # No currencyRange metadata (mis-templated widget) → bare number. ({"amount": "500", "currency": "JPY"}, None, "500", None), # Missing amount → empty string (unchanged). ({"currency": "JPY"}, _BUYBACK_RANGE, "", None), # Bare scalar answer (non-dict) → stringified, unchanged. ("1234", _BUYBACK_RANGE, "1234", None), ], ) def test_serialize_amount_widget_currency_encoding( answer, currency_range, expected_value, expected_currency, ): """The whole form ITEM is the contract, not just its ``value``.""" from app.approval.schemas import ApprovalTemplateWidget from app.approval.service import ApprovalService option = ( {"currencyRange": list(currency_range), "isCapital": False} if currency_range is not None else None ) widget = ApprovalTemplateWidget( id="amount", name="Repurchase price", type="amount", required=True, raw_option=option, ) payload = json.loads( ApprovalService(db=None)._serialize_feishu_form( [widget], {"amount": answer}, ), ) assert len(payload) == 1, payload item = payload[0] assert item["value"] == expected_value # ``value`` must never become an object — that is the 1395006 shape. assert not isinstance(item["value"], (dict, list)) assert item.get("currency") == expected_currency if expected_currency is None: assert "currency" not in item, item