From 7ffdf5f80ae3c5d232a7e14507ab2eb338166abd Mon Sep 17 00:00:00 2001 From: Suleiman Latrsh Date: Wed, 12 Aug 2026 15:46:29 -0400 Subject: [PATCH 1/2] Create new Release. Assisted-By: devx/75813201-8afc-470d-b349-7c262774f128 --- .github/workflows/gardener-notify-event.yml | 35 +++ .github/workflows/gardener-notify-slack.yml | 116 ++++++++++ .github/workflows/publish.yml | 4 +- CHANGELOG.md | 6 + requirements-dev.in | 10 + requirements-dev.txt | 242 ++++++++++++++++++++ shopify_app/_version.py | 2 +- shopify_app/exchange/refresh_token.py | 23 +- shopify_app/exchange/token_exchange.py | 44 ++-- shopify_app/helpers/app_home_redirect.py | 5 + shopify_app/verify/app_proxy.py | 18 ++ 11 files changed, 486 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/gardener-notify-event.yml create mode 100644 .github/workflows/gardener-notify-slack.yml create mode 100644 requirements-dev.in create mode 100644 requirements-dev.txt diff --git a/.github/workflows/gardener-notify-event.yml b/.github/workflows/gardener-notify-event.yml new file mode 100644 index 0000000..f03b719 --- /dev/null +++ b/.github/workflows/gardener-notify-event.yml @@ -0,0 +1,35 @@ +name: Gardener - Notify Event +# Tiny event capturer: stashes the triggering issue/PR payload as an artifact +# for `gardener-notify-slack.yml` to pick up via workflow_run. +# +# Why two workflows? When Dependabot triggers a workflow, GitHub forces +# GITHUB_TOKEN to read-only and hides Actions secrets — so labeling and +# Slack posting from this workflow would fail on every Dependabot PR. A +# workflow_run-triggered follow-up runs in the default-branch context with +# full permissions and secret access, regardless of the upstream actor. +# +# Uses pull_request_target so fork-opened PRs still produce an artifact. +# No code is checked out here; this workflow only reads the pre-parsed +# event payload, so there is no pwn-request surface. +on: + issues: + types: [opened, labeled] + pull_request_target: + types: [opened, labeled] + +permissions: + contents: read + +jobs: + capture: + if: github.event.action == 'opened' || github.event.label.name == 'devtools-gardener' + runs-on: ubuntu-latest + steps: + - name: Stash event payload + run: cp "$GITHUB_EVENT_PATH" event.json + + - uses: actions/upload-artifact@v4 + with: + name: gardener-event + path: event.json + retention-days: 1 diff --git a/.github/workflows/gardener-notify-slack.yml b/.github/workflows/gardener-notify-slack.yml new file mode 100644 index 0000000..8bcb5a8 --- /dev/null +++ b/.github/workflows/gardener-notify-slack.yml @@ -0,0 +1,116 @@ +name: Gardener - Notify Slack +# Runs after `Gardener - Notify Event` completes and does the real work: +# applies the devtools-gardener label and posts a summary to Slack. +# +# The workflow_run trigger runs this job in the default-branch context with +# full GITHUB_TOKEN permissions and Actions secret access — this is what +# lets it succeed for Dependabot-opened PRs, where the upstream event +# workflow can't label or reach secrets directly. +on: + workflow_run: + workflows: ['Gardener - Notify Event'] + types: [completed] + +permissions: + contents: read + issues: write + pull-requests: write + actions: read + +jobs: + notify: + # `conclusion == success` also covers runs where the capture job was + # skipped by its `if` gate (no matching label, etc.) — in that case + # no artifact was uploaded, so the download step below no-ops. + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - name: Download event payload + id: download + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: gardener-event + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Add devtools-gardener label + if: steps.download.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + ACTION=$(jq -r '.action' event.json) + # On `labeled` events the label is already there — skip. + if [ "$ACTION" != "opened" ]; then + exit 0 + fi + NUMBER=$(jq -r '(.issue // .pull_request).number' event.json) + if jq -e 'has("pull_request")' event.json > /dev/null; then + gh pr edit "$NUMBER" --add-label devtools-gardener + else + gh issue edit "$NUMBER" --add-label devtools-gardener + fi + + - name: Post to Slack + if: steps.download.outcome == 'success' + continue-on-error: true + env: + SLACK_BOT_TOKEN: ${{ secrets.SLACK_GARDENER_BOT_TOKEN }} + SLACK_CHANNEL_ID: ${{ vars.GARDENER_SLACK_CHANNEL_ID }} + run: | + KIND=$(jq -r 'if has("pull_request") then "PR" else "Issue" end' event.json) + # Pull the body out, truncate, then convert GitHub Markdown to + # Slack mrkdwn. Links and fenced code blocks are stashed before + # the HTML-escape pass so their contents survive verbatim (a `&` + # inside a URL must stay raw, and code content shouldn't be + # mangled). Blockquote `> ` markers are also stashed so the + # `>` → `>` escape doesn't break them. Everything else is + # HTML-escaped so user-supplied `<`, `>`, `&` can't collide + # with Slack link syntax or injected mentions like . + BODY=$(jq -r '(.issue // .pull_request).body // ""' event.json) + if [ ${#BODY} -gt 1000 ]; then + BODY="${BODY:0:1000}…" + fi + BODY=$(printf '%s' "$BODY" | perl -0777 -pe ' + my @u; + s{\[([^\]]+)\]\(([^)]+)\)}{push @u, $2; "\x01$#u\x02$1\x03"}ge; + my @c; + s{^```[^\n]*\n(.*?)\n```$}{push @c, $1; "\x04$#c\x05"}gems; + s/^> /\x06/gm; + s/^#{1,6}\s+(.+)$/*$1*/gm; + s/\*\*(.+?)\*\*/*$1*/g; + s/^(\s*)- \[x\]\s+/$1✓ /gm; + s/^(\s*)[-*]\s+/$1• /gm; + s/&/&/g; + s//>/g; + s/\x06/> /g; + s{\x01(\d+)\x02(.*?)\x03}{"<$u[$1]|$2>"}ge; + s{\x04(\d+)\x05}{"```\n$c[$1]\n```"}ge; + ') + jq \ + --arg channel "$SLACK_CHANNEL_ID" \ + --arg kind "$KIND" \ + --arg body "$BODY" \ + ' + def escape: gsub("&";"&") | gsub("<";"<") | gsub(">";">"); + + (.issue // .pull_request) as $i + | ([$i.labels[]?.name | select(. != "devtools-gardener")] + | map("`\(.)`") | join(" ")) as $labels + | (if $kind == "PR" + then " · \($i.changed_files) files, +\($i.additions)/-\($i.deletions)" + + (if $i.draft then " · draft" else "" end) + else "" end) as $meta + | [ "*<\($i.html_url)|\($kind) #\($i.number)>* — \(($i.title | escape))", + "_opened by \($i.user.login)\($meta)_" ] + + (if $body != "" then [$body] else [] end) + + (if $labels != "" then [$labels] else [] end) + | join("\n") as $msg + | { channel: $channel, text: "\($kind) #\($i.number): \($i.title)", + blocks: [{ type: "section", text: { type: "mrkdwn", text: $msg } }] } + ' event.json | curl -sf -X POST \ + -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ + -H 'Content-type: application/json; charset=utf-8' \ + -d @- https://slack.com/api/chat.postMessage diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 55e50ff..ddb67cc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,10 +62,10 @@ jobs: python-version: "3.12" - name: Install build dependencies - run: python -m pip install --upgrade pip build + run: python -m pip install --require-hashes -r requirements-dev.txt - name: Build package - run: python -m build + run: python -m build --no-isolation - name: Upload build artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac3dd9..e88ef53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.4] + +- Verify the dest property is not a malicious URL before making a token exchange request +- Reject App Proxy requests with multiple `shop` query parameters with a 401 response. +- Refreshing a non-expiring token now returns a no-refresh-needed result instead of an error + ## [0.1.3] - Add optional `expiring` parameter to `exchange_using_token_exchange`. Defaults to `True`. Pass `False` to request a non-expiring token (no `refresh_token` or `refresh_token_expires`). If `False`, `refresh_token` and `refresh_token_expires` will be `None` in result. diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..f28bc45 --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,10 @@ +# Top-level Python dependencies for local development, release validation, and CI. +# Keep pyproject.toml and requirements.txt looser for package consumers. +black==24.8.0 +build==1.2.2.post1 +flake8==5.0.4 +hatchling==1.27.0 +httpx==0.28.1 +isort==5.13.2 +mypy==1.14.1 +PyJWT==2.9.0 diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f6e6f79 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,242 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile packages/python/requirements-dev.in --generate-hashes --python-version 3.8 --python-platform x86_64-manylinux2014 --output-file packages/python/requirements-dev.txt +anyio==4.5.2 \ + --hash=sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b \ + --hash=sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f + # via httpx +black==24.8.0 \ + --hash=sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6 \ + --hash=sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e \ + --hash=sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f \ + --hash=sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018 \ + --hash=sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e \ + --hash=sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd \ + --hash=sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4 \ + --hash=sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed \ + --hash=sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2 \ + --hash=sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42 \ + --hash=sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af \ + --hash=sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb \ + --hash=sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368 \ + --hash=sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb \ + --hash=sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af \ + --hash=sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed \ + --hash=sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47 \ + --hash=sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2 \ + --hash=sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a \ + --hash=sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c \ + --hash=sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920 \ + --hash=sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1 + # via -r packages/python/requirements-dev.in +build==1.2.2.post1 \ + --hash=sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5 \ + --hash=sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7 + # via -r packages/python/requirements-dev.in +certifi==2026.5.20 \ + --hash=sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897 \ + --hash=sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d + # via + # httpcore + # httpx +click==8.1.8 \ + --hash=sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2 \ + --hash=sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a + # via black +exceptiongroup==1.3.1 \ + --hash=sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219 \ + --hash=sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598 + # via anyio +flake8==5.0.4 \ + --hash=sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db \ + --hash=sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248 + # via -r packages/python/requirements-dev.in +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +hatchling==1.27.0 \ + --hash=sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6 \ + --hash=sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b + # via -r packages/python/requirements-dev.in +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via -r packages/python/requirements-dev.in +idna==3.15 \ + --hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 \ + --hash=sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc + # via + # anyio + # httpx +importlib-metadata==8.5.0 \ + --hash=sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b \ + --hash=sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7 + # via build +isort==5.13.2 \ + --hash=sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109 \ + --hash=sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6 + # via -r packages/python/requirements-dev.in +mccabe==0.7.0 \ + --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ + --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e + # via flake8 +mypy==1.14.1 \ + --hash=sha256:07ba89fdcc9451f2ebb02853deb6aaaa3d2239a236669a63ab3801bbf923ef5c \ + --hash=sha256:0c911fde686394753fff899c409fd4e16e9b294c24bfd5e1ea4675deae1ac6fd \ + --hash=sha256:183cf0a45457d28ff9d758730cd0210419ac27d4d3f285beda038c9083363b1f \ + --hash=sha256:1fb545ca340537d4b45d3eecdb3def05e913299ca72c290326be19b3804b39c0 \ + --hash=sha256:27fc248022907e72abfd8e22ab1f10e903915ff69961174784a3900a8cba9ad9 \ + --hash=sha256:2ae753f5c9fef278bcf12e1a564351764f2a6da579d4a81347e1d5a15819997b \ + --hash=sha256:30ff5ef8519bbc2e18b3b54521ec319513a26f1bba19a7582e7b1f58a6e69f14 \ + --hash=sha256:3888a1816d69f7ab92092f785a462944b3ca16d7c470d564165fe703b0970c35 \ + --hash=sha256:44bf464499f0e3a2d14d58b54674dee25c031703b2ffc35064bd0df2e0fac319 \ + --hash=sha256:46c756a444117c43ee984bd055db99e498bc613a70bbbc120272bd13ca579fbc \ + --hash=sha256:499d6a72fb7e5de92218db961f1a66d5f11783f9ae549d214617edab5d4dbdbb \ + --hash=sha256:52686e37cf13d559f668aa398dd7ddf1f92c5d613e4f8cb262be2fb4fedb0fcb \ + --hash=sha256:553c293b1fbdebb6c3c4030589dab9fafb6dfa768995a453d8a5d3b23784af2e \ + --hash=sha256:57961db9795eb566dc1d1b4e9139ebc4c6b0cb6e7254ecde69d1552bf7613f60 \ + --hash=sha256:7084fb8f1128c76cd9cf68fe5971b37072598e7c31b2f9f95586b65c741a9d31 \ + --hash=sha256:7d54bd85b925e501c555a3227f3ec0cfc54ee8b6930bd6141ec872d1c572f81f \ + --hash=sha256:7ec88144fe9b510e8475ec2f5f251992690fcf89ccb4500b214b4226abcd32d6 \ + --hash=sha256:8b21525cb51671219f5307be85f7e646a153e5acc656e5cebf64bfa076c50107 \ + --hash=sha256:8b4e3413e0bddea671012b063e27591b953d653209e7a4fa5e48759cda77ca11 \ + --hash=sha256:8c6d94b16d62eb3e947281aa7347d78236688e21081f11de976376cf010eb31a \ + --hash=sha256:8edc07eeade7ebc771ff9cf6b211b9a7d93687ff892150cb5692e4f4272b0837 \ + --hash=sha256:8f845a00b4f420f693f870eaee5f3e2692fa84cc8514496114649cfa8fd5e2c6 \ + --hash=sha256:8fa2220e54d2946e94ab6dbb3ba0a992795bd68b16dc852db33028df2b00191b \ + --hash=sha256:90716d8b2d1f4cd503309788e51366f07c56635a3309b0f6a32547eaaa36a64d \ + --hash=sha256:92c3ed5afb06c3a8e188cb5da4984cab9ec9a77ba956ee419c68a388b4595255 \ + --hash=sha256:ad3301ebebec9e8ee7135d8e3109ca76c23752bac1e717bc84cd3836b4bf3eae \ + --hash=sha256:b66a60cc4073aeb8ae00057f9c1f64d49e90f918fbcef9a977eb121da8b8f1d1 \ + --hash=sha256:ba24549de7b89b6381b91fbc068d798192b1b5201987070319889e93038967a8 \ + --hash=sha256:bce23c7377b43602baa0bd22ea3265c49b9ff0b76eb315d6c34721af4cdf1d9b \ + --hash=sha256:c99f27732c0b7dc847adb21c9d47ce57eb48fa33a17bc6d7d5c5e9f9e7ae5bac \ + --hash=sha256:cb9f255c18052343c70234907e2e532bc7e55a62565d64536dbc7706a20b78b9 \ + --hash=sha256:d4b19b03fdf54f3c5b2fa474c56b4c13c9dbfb9a2db4370ede7ec11a2c5927d9 \ + --hash=sha256:d64169ec3b8461311f8ce2fd2eb5d33e2d0f2c7b49116259c51d0d96edee48d1 \ + --hash=sha256:dbec574648b3e25f43d23577309b16534431db4ddc09fda50841f1e34e64ed34 \ + --hash=sha256:e0fe0f5feaafcb04505bcf439e991c6d8f1bf8b15f12b05feeed96e9e7bf1427 \ + --hash=sha256:f2a0ecc86378f45347f586e4163d1769dd81c5a223d577fe351f26b179e148b1 \ + --hash=sha256:f995e511de847791c3b11ed90084a7a0aafdc074ab88c5a9711622fe4751138c \ + --hash=sha256:fad79bfe3b65fe6a1efaed97b445c3d37f7be9fdc348bdb2d7cac75579607c89 + # via -r packages/python/requirements-dev.in +mypy-extensions==1.1.0 \ + --hash=sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505 \ + --hash=sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558 + # via + # black + # mypy +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via + # black + # build + # hatchling +pathspec==0.12.1 \ + --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ + --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 + # via + # black + # hatchling +platformdirs==4.3.6 \ + --hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \ + --hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb + # via black +pluggy==1.5.0 \ + --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \ + --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669 + # via hatchling +pycodestyle==2.9.1 \ + --hash=sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785 \ + --hash=sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b + # via flake8 +pyflakes==2.5.0 \ + --hash=sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2 \ + --hash=sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3 + # via flake8 +pyjwt==2.9.0 \ + --hash=sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850 \ + --hash=sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c + # via -r packages/python/requirements-dev.in +pyproject-hooks==1.2.0 \ + --hash=sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8 \ + --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 + # via build +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via anyio +tomli==2.4.1 \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via + # black + # build + # hatchling + # mypy +trove-classifiers==2026.5.22.10 \ + --hash=sha256:01fe864225726e03efb843827ecabfe319fc4dee8dd66d65b8996cb09be46e2c \ + --hash=sha256:5477e9974e91904fb2cfa4a7581ab6e2f30c2c38d847fd00ed866080748101d5 + # via hatchling +typing-extensions==4.13.2 \ + --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \ + --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef + # via + # anyio + # black + # exceptiongroup + # mypy +zipp==3.20.2 \ + --hash=sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350 \ + --hash=sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29 + # via importlib-metadata diff --git a/shopify_app/_version.py b/shopify_app/_version.py index d2a34c5..0bedf82 100644 --- a/shopify_app/_version.py +++ b/shopify_app/_version.py @@ -2,4 +2,4 @@ from __future__ import annotations -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/shopify_app/exchange/refresh_token.py b/shopify_app/exchange/refresh_token.py index d62258b..58f8ee5 100644 --- a/shopify_app/exchange/refresh_token.py +++ b/shopify_app/exchange/refresh_token.py @@ -58,7 +58,7 @@ def refresh_access_token( # Validate token expiration (returns early if token still valid or refresh token expired) should_continue, response = _validate_token_expiry( - expires, refresh_token_expires, shop + expires, refresh_token_expires, shop, refresh_token ) if not should_continue: if response is None: @@ -241,6 +241,7 @@ def _validate_token_expiry( expires: str, refresh_token_expires: str, shop: str, + refresh_token: Optional[str], ) -> Tuple[bool, Optional[TokenExchangeResult]]: """Validate token expiration and determine if refresh is needed. @@ -276,6 +277,24 @@ def _validate_token_expiry( except ValueError: pass # Invalid date format, continue with refresh + # A non-expiring token has no expiry and no refresh token; it never needs refreshing. + # (An expiring token that still has a refresh token must fall through and refresh.) + if not expires and not refresh_token: + return ( + False, + TokenExchangeResult( + ok=True, + shop=shop, + access_token=None, + log=Log( + code="non_expiring_no_refresh_needed", + detail="Access token does not expire, so no refresh is needed. Proceed with business logic.", + ), + http_logs=[], + response=Res(status=200, body="", headers={}), + ), + ) + # Check if access token is still valid (with 60-second buffer) if expires: try: @@ -499,7 +518,7 @@ async def refresh_access_token_async( # Validate token expiration (returns early if token still valid or refresh token expired) should_continue, response = _validate_token_expiry( - expires, refresh_token_expires, shop + expires, refresh_token_expires, shop, refresh_token ) if not should_continue: if response is None: diff --git a/shopify_app/exchange/token_exchange.py b/shopify_app/exchange/token_exchange.py index f8c85bc..ab2cf07 100644 --- a/shopify_app/exchange/token_exchange.py +++ b/shopify_app/exchange/token_exchange.py @@ -8,6 +8,7 @@ import asyncio import dataclasses +import re import time from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast @@ -327,21 +328,36 @@ def _validate_id_token( ), ) - if ".myshopify.com" not in shop_from_token: - return ( - False, - TokenExchangeResult( - ok=False, - shop=shop, - access_token=None, - log=Log( - code="configuration_error", - detail="Expected idToken.claims.dest to be a valid shop URL (e.g., 'https://shop.myshopify.com' or 'shop.myshopify.com')", - ), - http_logs=[], - response=Res(status=500, body="", headers={}), + invalid_dest_error = ( + False, + TokenExchangeResult( + ok=False, + shop=shop, + access_token=None, + log=Log( + code="configuration_error", + detail="Expected idToken.claims.dest to be a valid shop URL (e.g., 'https://shop.myshopify.com' or 'shop.myshopify.com')", ), - ) + http_logs=[], + response=Res(status=500, body="", headers={}), + ), + ) + + # Strip protocol prefix + dest_without_protocol = shop_from_token.replace("https://", "").replace( + "http://", "" + ) + + # dest must end with .myshopify.com (not just contain it anywhere) + if not dest_without_protocol.endswith(".myshopify.com"): + return invalid_dest_error + + # Extract shop name by removing .myshopify.com suffix + shop_name_part = dest_without_protocol[: -len(".myshopify.com")] + + # Validate shop name against regex + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9\-]*$", shop_name_part): + return invalid_dest_error return (True, None) diff --git a/shopify_app/helpers/app_home_redirect.py b/shopify_app/helpers/app_home_redirect.py index b610c2d..93690c0 100644 --- a/shopify_app/helpers/app_home_redirect.py +++ b/shopify_app/helpers/app_home_redirect.py @@ -171,6 +171,11 @@ def _is_valid_relative_url(redirect_url: str) -> bool: if redirect_url.startswith("//"): return False + # Must not be backslash-prefixed (/\evil.com) — browsers normalize \ to / + # per the WHATWG URL Standard, turning it into a protocol-relative URL + if len(redirect_url) > 1 and redirect_url[1] == "\\": + return False + return True diff --git a/shopify_app/verify/app_proxy.py b/shopify_app/verify/app_proxy.py index 1089356..c80eeff 100644 --- a/shopify_app/verify/app_proxy.py +++ b/shopify_app/verify/app_proxy.py @@ -71,6 +71,24 @@ def verify_app_proxy_req( else: params[key] = value + # Reject requests with multiple shop URL params + if isinstance(params.get("shop"), list): + return ResultWithLoggedInCustomerId( + ok=False, + shop=None, + logged_in_customer_id=None, + log=LogWithReq( + code="multiple_shop_parameters", + detail="Request has multiple `shop` query parameters. Respond 401 Unauthorized using the provided response.", + req=req, + ), + response=Res( + status=401, + body="Unauthorized", + headers={}, + ), + ) + # Check for missing timestamp if "timestamp" not in params: return ResultWithLoggedInCustomerId( From 479459a0fd53c5b01c5b8654c728bed53b4d32d5 Mon Sep 17 00:00:00 2001 From: Suleiman Latrsh Date: Thu, 13 Aug 2026 14:06:44 -0400 Subject: [PATCH 2/2] Exclude requirements-dev.in from public repo (keep requirements-dev.txt) Assisted-By: devx/75813201-8afc-470d-b349-7c262774f128 --- requirements-dev.in | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 requirements-dev.in diff --git a/requirements-dev.in b/requirements-dev.in deleted file mode 100644 index f28bc45..0000000 --- a/requirements-dev.in +++ /dev/null @@ -1,10 +0,0 @@ -# Top-level Python dependencies for local development, release validation, and CI. -# Keep pyproject.toml and requirements.txt looser for package consumers. -black==24.8.0 -build==1.2.2.post1 -flake8==5.0.4 -hatchling==1.27.0 -httpx==0.28.1 -isort==5.13.2 -mypy==1.14.1 -PyJWT==2.9.0