Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .cspell/custom-words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ okhttp
opentelemetry
otelgrpc
otelhttp
otherpisp
Otherville
OURCYGPATTERN
Palo
Expand All @@ -127,6 +128,11 @@ Payoneer
paypal
Payplug
pids
PISP
Pisp
Pisps
pisp
pisps
pmezard
proguard
Proguard
Expand All @@ -149,6 +155,7 @@ ropeproject
RPCURL
Rulebook
screenreaders
SECP
setlocal
sharedpref
Shopcider
Expand All @@ -158,6 +165,7 @@ Signifyd
skus
solana
Splitit
spoofable
Spyder
spyderproject
spyproject
Expand Down
11 changes: 9 additions & 2 deletions .github/workflows/linter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ on:
pull_request:
branches: [main]

permissions:
contents: read
statuses: write
pull-requests: write

jobs:
build:
name: Lint Code Base
runs-on: ubuntu-latest

steps:
- name: Checkout Code
uses: actions/checkout@v5
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
fetch-depth: 0
persist-credentials: false

- name: Lint Code Base
uses: super-linter/super-linter/slim@v8
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
env:
DEFAULT_BRANCH: main
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand All @@ -25,6 +31,7 @@ jobs:
VALIDATE_ALL_CODEBASE: false
FILTER_REGEX_EXCLUDE: "^(\\.github/|\\.vscode/|code/samples/).*|CODE_OF_CONDUCT.md|CHANGELOG.md"
VALIDATE_BIOME_FORMAT: false
VALIDATE_BIOME_LINT: false
VALIDATE_PYTHON_BLACK: false
VALIDATE_PYTHON_FLAKE8: false
VALIDATE_PYTHON_ISORT: false
Expand Down
29 changes: 10 additions & 19 deletions code/sdk/python/ap2/sdk/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,18 @@ class MandateContext(BaseModel):


def merchant_matches(candidate: Merchant, target: Merchant) -> bool:
"""Match merchants by ``id`` (preferred) or by ``name`` + ``website``."""
"""Match merchants by a stable, non-empty ``id``.

Merchant/payee identity must be established by ``id``. The display
fields (``name`` and ``website``) are attacker-controllable and are
never sufficient to prove that two merchant/payee objects are the same
actor, so an empty or missing ``id`` on either side never matches (see
issue #315).
"""
candidate_id = candidate.id
target_id = target.id if isinstance(target, Merchant) else target.get('id')

if isinstance(target, Merchant):
target_id = target.id
target_name = target.name
target_website = target.website
else:
target_id = target.get('id')
target_name = target.get('name')
target_website = target.get('website')

if candidate_id and target_id:
return candidate_id == target_id

return (
candidate.name == target_name
and bool(candidate.name)
and candidate.website == target_website
and bool(candidate.website)
)
return bool(candidate_id) and candidate_id == target_id


class PaymentConstraintEvaluator(ABC):
Expand Down
47 changes: 28 additions & 19 deletions code/sdk/python/ap2/tests/constraints_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,29 +96,17 @@ def _checkout(merchant=None, line_items=None, **kw):
pytest.param(
Merchant(id='m-1', name='A', website='https://a.com'),
Merchant(id='m-1', name='B', website='https://b.com'),
id='by_id',
),
pytest.param(
Merchant(id='', name='Shop', website='https://shop.com'),
Merchant(id='', name='Shop', website='https://shop.com'),
id='by_name_and_website',
id='by_id_ignores_display_fields',
),
pytest.param(
Merchant(id='m-1', name='A'),
Merchant(id='m-1', name='B').model_dump(mode='json'),
id='dict_target_by_id',
),
pytest.param(
Merchant(id='', name='Shop', website='https://shop.com'),
Merchant(id='', name='Shop', website='https://shop.com').model_dump(
mode='json'
),
id='dict_target_by_name_and_website',
),
],
)
def test_merchant_matches(candidate, target):
"""Merchants that should match do match."""
"""Merchants with equal, non-empty ids match regardless of display."""
assert merchant_matches(candidate, target)


Expand All @@ -130,16 +118,37 @@ def test_merchant_matches(candidate, target):
Merchant(id='m-2', name='A'),
id='different_id',
),
pytest.param(
Merchant(id='', name='Shop'),
Merchant(id='', name='Shop'),
id='name_only_without_website',
),
pytest.param(
Merchant(id='m-1', name='A'),
Merchant(id='m-2', name='A').model_dump(mode='json'),
id='dict_target_different_id',
),
# Regression for issue #315: display fields (name + website) are
# spoofable and must never establish identity when an id is empty.
pytest.param(
Merchant(id='', name='Shop', website='https://shop.com'),
Merchant(id='', name='Shop', website='https://shop.com'),
id='spoofed_name_website_empty_id',
),
pytest.param(
Merchant(id='', name='Shop', website='https://shop.com'),
Merchant(id='', name='Shop', website='https://shop.com').model_dump(
mode='json'
),
id='spoofed_name_website_empty_id_dict',
),
# An authorized merchant with a real id must not be matched by an
# attacker who supplies an empty id but copies name + website.
pytest.param(
Merchant(id='m-1', name='Shop', website='https://shop.com'),
Merchant(id='', name='Shop', website='https://shop.com'),
id='real_id_vs_empty_id_same_display',
),
pytest.param(
Merchant(id='', name='Shop'),
Merchant(id='', name='Shop'),
id='name_only_without_website',
),
pytest.param(
Merchant(id='', name='Shop', website=''),
Merchant(id='', name='Shop', website=''),
Expand Down
Loading