-
Notifications
You must be signed in to change notification settings - Fork 1.7k
[PM-36574] fix: scope provider client invoice report to the authorized provider #7770
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
51157f3
[PM-36574] fix: scope provider client invoice report to the authorizeβ¦
connerbw 4286cfe
Dispose SqlConnection via await using in ProviderInvoiceItemRepository
connerbw ffda04f
Rename stored procedure to ProviderInvoiceItem_ReadByProviderIdInvoicβ¦
connerbw 15ecb7c
Merge branch 'main' into billing/pm-36574/provider-invoice-csv-idor
connerbw File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
15 changes: 15 additions & 0 deletions
15
src/Sql/dbo/Billing/Stored Procedures/ProviderInvoiceItem_ReadByProviderIdInvoiceId.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| CREATE PROCEDURE [dbo].[ProviderInvoiceItem_ReadByProviderIdInvoiceId] | ||
| @ProviderId UNIQUEIDENTIFIER, | ||
| @InvoiceId VARCHAR (50) | ||
| AS | ||
| BEGIN | ||
| SET NOCOUNT ON | ||
|
|
||
| SELECT | ||
| * | ||
| FROM | ||
| [dbo].[ProviderInvoiceItemView] | ||
| WHERE | ||
| [ProviderId] = @ProviderId | ||
| AND [InvoiceId] = @InvoiceId | ||
| END |
147 changes: 147 additions & 0 deletions
147
test/Api.IntegrationTest/Billing/Controllers/ProviderBillingControllerAuthorizationTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| ο»Ώusing System.Net; | ||
| using Bit.Api.IntegrationTest.Factories; | ||
| using Bit.Api.IntegrationTest.Helpers; | ||
| using Bit.Core.AdminConsole.Entities.Provider; | ||
| using Bit.Core.AdminConsole.Enums.Provider; | ||
| using Bit.Core.AdminConsole.Repositories; | ||
| using Bit.Core.Billing.Providers.Entities; | ||
| using Bit.Core.Billing.Providers.Repositories; | ||
| using Bit.Core.Repositories; | ||
| using Xunit; | ||
|
|
||
| namespace Bit.Api.IntegrationTest.Billing.Controllers; | ||
|
|
||
| /// <summary> | ||
| /// Integration tests for the provider client invoice CSV endpoint | ||
| /// (GET /providers/{providerId}/billing/invoices/{invoiceId}) on ProviderBillingController, | ||
| /// focusing on cross-provider authorization. | ||
| /// | ||
| /// Reproduces VULN-565 (PM-36574): the action authorizes the caller against the route providerId, | ||
| /// but the report service loaded ProviderInvoiceItem rows by the attacker-supplied invoiceId without | ||
| /// checking the invoice belongs to the authorized provider. A provider admin for provider A could | ||
| /// therefore retrieve provider B's client invoice CSV by passing B's invoiceId through A's route. | ||
| /// | ||
| /// The report must be scoped to the authorized provider, so the victim provider's client billing | ||
| /// data must never appear in the attacker's response. | ||
| /// </summary> | ||
| public class ProviderBillingControllerAuthorizationTests : IClassFixture<ApiApplicationFactory>, IAsyncLifetime | ||
| { | ||
| private readonly HttpClient _client; | ||
| private readonly ApiApplicationFactory _factory; | ||
| private readonly LoginHelper _loginHelper; | ||
|
|
||
| private string _attackerAdminEmail = null!; | ||
| private Provider _attackerProvider = null!; | ||
| private Provider _victimProvider = null!; | ||
| private string _victimInvoiceId = null!; | ||
|
|
||
| // Distinctive victim values that must never leak into the attacker's response. | ||
| private const string VictimClientName = "Victim Client Organization"; | ||
| private const string VictimPlanName = "Enterprise (Annually)"; | ||
|
|
||
| public ProviderBillingControllerAuthorizationTests(ApiApplicationFactory factory) | ||
| { | ||
| _factory = factory; | ||
| _client = _factory.CreateClient(); | ||
| _loginHelper = new LoginHelper(_factory, _client); | ||
| } | ||
|
|
||
| public async Task InitializeAsync() | ||
| { | ||
| var userRepository = _factory.GetService<IUserRepository>(); | ||
| var providerRepository = _factory.GetService<IProviderRepository>(); | ||
| var providerUserRepository = _factory.GetService<IProviderUserRepository>(); | ||
| var providerInvoiceItemRepository = _factory.GetService<IProviderInvoiceItemRepository>(); | ||
|
|
||
| // Attacker: a provider admin of their own billable provider (provider A). | ||
| _attackerAdminEmail = $"vuln565-attacker-{Guid.NewGuid()}@test.com"; | ||
| await _factory.LoginWithNewAccount(_attackerAdminEmail); | ||
| var attackerAdmin = await userRepository.GetByEmailAsync(_attackerAdminEmail); | ||
|
|
||
| _attackerProvider = await CreateBillableProviderAsync(providerRepository, "Attacker Provider"); | ||
| await providerUserRepository.CreateAsync(new ProviderUser | ||
| { | ||
| ProviderId = _attackerProvider.Id, | ||
| UserId = attackerAdmin!.Id, | ||
| Type = ProviderUserType.ProviderAdmin, | ||
| Status = ProviderUserStatusType.Confirmed, | ||
| Key = Guid.NewGuid().ToString() | ||
| }); | ||
|
|
||
| // Victim: a different provider (provider B) that owns an invoice item. The attacker has no | ||
| // membership in this provider. | ||
| _victimProvider = await CreateBillableProviderAsync(providerRepository, "Victim Provider"); | ||
| _victimInvoiceId = $"in_{Guid.NewGuid():N}"; | ||
| await providerInvoiceItemRepository.CreateAsync(new ProviderInvoiceItem | ||
| { | ||
| ProviderId = _victimProvider.Id, | ||
| InvoiceId = _victimInvoiceId, | ||
| InvoiceNumber = "INV-VICTIM-001", | ||
| ClientId = Guid.NewGuid(), | ||
| ClientName = VictimClientName, | ||
| PlanName = VictimPlanName, | ||
| AssignedSeats = 42, | ||
| UsedSeats = 17, | ||
| Total = 1234.56m | ||
| }); | ||
| } | ||
|
|
||
| public Task DisposeAsync() | ||
| { | ||
| _client.Dispose(); | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Control: the attacker cannot use the victim provider's own route β they are not a provider | ||
| /// admin of provider B, so authorization rejects the request. Passes before and after the fix; | ||
| /// anchors the IDOR test below by confirming the attacker has no legitimate access to provider B. | ||
| /// </summary> | ||
| [Fact] | ||
| public async Task GenerateClientInvoiceReport_ThroughVictimProviderRoute_IsUnauthorized() | ||
| { | ||
| await _loginHelper.LoginAsync(_attackerAdminEmail); | ||
|
|
||
| var response = await _client.GetAsync( | ||
| $"providers/{_victimProvider.Id}/billing/invoices/{_victimInvoiceId}"); | ||
|
|
||
| Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Reproduces VULN-565: the attacker requests the victim's invoiceId through their OWN provider's | ||
| /// route, which passes authorization. The report must be scoped to the authorized provider, so | ||
| /// none of the victim provider's client billing data may be returned. | ||
| /// </summary> | ||
| [Fact] | ||
| public async Task GenerateClientInvoiceReport_ForInvoiceOwnedByAnotherProvider_DoesNotReturnVictimData() | ||
| { | ||
| await _loginHelper.LoginAsync(_attackerAdminEmail); | ||
|
|
||
| var response = await _client.GetAsync( | ||
| $"providers/{_attackerProvider.Id}/billing/invoices/{_victimInvoiceId}"); | ||
|
|
||
| var body = await response.Content.ReadAsStringAsync(); | ||
|
|
||
| // Core security invariant: the authorized provider does not own this invoice, so the victim | ||
| // provider's client billing data must not appear anywhere in the response. | ||
| Assert.DoesNotContain(VictimClientName, body); | ||
| Assert.DoesNotContain(VictimPlanName, body); | ||
|
|
||
| // The endpoint must not hand back a CSV for an invoice the provider doesn't own; the | ||
| // authorized provider has no such invoice, so the scoped lookup yields nothing -> 404. | ||
| Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); | ||
| } | ||
|
|
||
| private static Task<Provider> CreateBillableProviderAsync(IProviderRepository providerRepository, string name) => | ||
| providerRepository.CreateAsync(new Provider | ||
| { | ||
| Name = name, | ||
| BillingEmail = $"{name.Replace(" ", "-").ToLowerInvariant()}@example.com", | ||
| Type = ProviderType.Msp, | ||
| Status = ProviderStatusType.Billable, | ||
| Enabled = true, | ||
| GatewayCustomerId = $"cus_{Guid.NewGuid():N}", | ||
| GatewaySubscriptionId = $"sub_{Guid.NewGuid():N}" | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
18 changes: 18 additions & 0 deletions
18
util/Migrator/DbScripts/2026-06-05_00_AddProviderInvoiceItemReadByProviderIdInvoiceId.sql
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| -- Scope the provider client invoice report to the authorized provider (VULN-565 / PM-36574). | ||
| -- Adds a provider-scoped lookup so a provider can only read invoice items it owns. | ||
| CREATE OR ALTER PROCEDURE [dbo].[ProviderInvoiceItem_ReadByProviderIdInvoiceId] | ||
| @ProviderId UNIQUEIDENTIFIER, | ||
| @InvoiceId VARCHAR (50) | ||
| AS | ||
| BEGIN | ||
| SET NOCOUNT ON | ||
|
|
||
| SELECT | ||
| * | ||
| FROM | ||
| [dbo].[ProviderInvoiceItemView] | ||
| WHERE | ||
| [ProviderId] = @ProviderId | ||
| AND [InvoiceId] = @InvoiceId | ||
| END | ||
| GO |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π Good catch with these.