From 750c5289499137c332ec699e474e50b71a7e44fd Mon Sep 17 00:00:00 2001 From: 0xMRMA Date: Thu, 27 Aug 2026 22:22:14 +0300 Subject: [PATCH] Encode dynamic API path values as segments --- index.ts | 48 +++++++++++++++------------ tests/unit-tests/api_client.test.ts | 50 ++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/index.ts b/index.ts index 97f9317..706e801 100644 --- a/index.ts +++ b/index.ts @@ -299,6 +299,12 @@ export class AppStoreServerAPIClient { }); } + private encodePathSegment(value: string): string { + const encodedValue = encodeURIComponent(value) + // WHATWG URL parsing normalizes encoded dot-only segments, so encode them one more time. + return encodedValue === "." || encodedValue === ".." ? encodedValue.replace(/\./g, "%252E") : encodedValue + } + /** * Uses a subscription’s product identifier to extend the renewal date for all of its eligible active subscribers. * @@ -321,7 +327,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/extend_a_subscription_renewal_date Extend a Subscription Renewal Date} */ public async extendSubscriptionRenewalDate(originalTransactionId: string, extendRenewalDateRequest: ExtendRenewalDateRequest): Promise { - return await this.makeRequest("/inApps/v1/subscriptions/extend/" + originalTransactionId, "PUT", {}, extendRenewalDateRequest, new ExtendRenewalDateResponseValidator(), 'application/json'); + return await this.makeRequest("/inApps/v1/subscriptions/extend/" + this.encodePathSegment(originalTransactionId), "PUT", {}, extendRenewalDateRequest, new ExtendRenewalDateResponseValidator(), 'application/json'); } /** @@ -339,7 +345,7 @@ export class AppStoreServerAPIClient { queryParameters["status"] = status.map(s => s.toString()) as [string]; } - return await this.makeRequest("/inApps/v1/subscriptions/" + anyTransactionId, "GET", queryParameters, null, new StatusResponseValidator(), undefined); + return await this.makeRequest("/inApps/v1/subscriptions/" + this.encodePathSegment(anyTransactionId), "GET", queryParameters, null, new StatusResponseValidator(), undefined); } /** @@ -357,7 +363,7 @@ export class AppStoreServerAPIClient { queryParameters["revision"] = [revision]; } - return await this.makeRequest("/inApps/v2/refund/lookup/" + anyTransactionId, "GET", queryParameters, null, new RefundHistoryResponseValidator(), undefined); + return await this.makeRequest("/inApps/v2/refund/lookup/" + this.encodePathSegment(anyTransactionId), "GET", queryParameters, null, new RefundHistoryResponseValidator(), undefined); } /** @@ -370,7 +376,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/get_status_of_subscription_renewal_date_extensions Get Status of Subscription Renewal Date Extensions} */ public async getStatusOfSubscriptionRenewalDateExtensions(requestIdentifier: string, productId: string): Promise { - return await this.makeRequest("/inApps/v1/subscriptions/extend/mass/" + productId + "/" + requestIdentifier, "GET", {}, null, new MassExtendRenewalDateStatusResponseValidator(), undefined); + return await this.makeRequest("/inApps/v1/subscriptions/extend/mass/" + this.encodePathSegment(productId) + "/" + this.encodePathSegment(requestIdentifier), "GET", {}, null, new MassExtendRenewalDateStatusResponseValidator(), undefined); } /** @@ -382,7 +388,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/get_test_notification_status Get Test Notification Status} */ public async getTestNotificationStatus(testNotificationToken: string): Promise { - return await this.makeRequest("/inApps/v1/notifications/test/" + testNotificationToken, "GET", {}, null, new CheckTestNotificationResponseValidator(), undefined); + return await this.makeRequest("/inApps/v1/notifications/test/" + this.encodePathSegment(testNotificationToken), "GET", {}, null, new CheckTestNotificationResponseValidator(), undefined); } /** @@ -441,7 +447,7 @@ export class AppStoreServerAPIClient { if (transactionHistoryRequest.revoked !== undefined) { queryParameters["revoked"] = [transactionHistoryRequest.revoked.toString()]; } - return await this.makeRequest("/inApps/" + version + "/history/" + anyTransactionId, "GET", queryParameters, null, new HistoryResponseValidator(), undefined); + return await this.makeRequest("/inApps/" + this.encodePathSegment(version) + "/history/" + this.encodePathSegment(anyTransactionId), "GET", queryParameters, null, new HistoryResponseValidator(), undefined); } /** @@ -453,7 +459,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/get_transaction_info Get Transaction Info} */ public async getTransactionInfo(transactionId: string): Promise { - return await this.makeRequest("/inApps/v1/transactions/" + transactionId, "GET", {}, null, new TransactionInfoResponseValidator(), undefined); + return await this.makeRequest("/inApps/v1/transactions/" + this.encodePathSegment(transactionId), "GET", {}, null, new TransactionInfoResponseValidator(), undefined); } /** @@ -465,7 +471,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/look_up_order_id Look Up Order ID} */ public async lookUpOrderId(orderId: string): Promise { - return await this.makeRequest("/inApps/v1/lookup/" + orderId, "GET", {}, null, new OrderLookupResponseValidator(), undefined); + return await this.makeRequest("/inApps/v1/lookup/" + this.encodePathSegment(orderId), "GET", {}, null, new OrderLookupResponseValidator(), undefined); } /** @@ -489,7 +495,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/send-consumption-information-v1 Send Consumption Information} */ public async sendConsumptionData(transactionId: string, consumptionRequest: ConsumptionRequestV1): Promise { - await this.makeRequest("/inApps/v1/transactions/consumption/" + transactionId, "PUT", {}, consumptionRequest, null, 'application/json'); + await this.makeRequest("/inApps/v1/transactions/consumption/" + this.encodePathSegment(transactionId), "PUT", {}, consumptionRequest, null, 'application/json'); } /** @@ -501,7 +507,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/send-consumption-information Send Consumption Information} */ public async sendConsumptionInformation(transactionId: string, consumptionRequest: ConsumptionRequest): Promise { - await this.makeRequest("/inApps/v2/transactions/consumption/" + transactionId, "PUT", {}, consumptionRequest, null, 'application/json'); + await this.makeRequest("/inApps/v2/transactions/consumption/" + this.encodePathSegment(transactionId), "PUT", {}, consumptionRequest, null, 'application/json'); } /** @@ -513,7 +519,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/set-app-account-token Set App Account Token} */ public async setAppAccountToken(originalTransactionId: string, updateAppAccountTokenRequest: UpdateAppAccountTokenRequest): Promise { - await this.makeRequest("/inApps/v1/transactions/" + originalTransactionId + "/appAccountToken", "PUT", {}, updateAppAccountTokenRequest, null, 'application/json'); + await this.makeRequest("/inApps/v1/transactions/" + this.encodePathSegment(originalTransactionId) + "/appAccountToken", "PUT", {}, updateAppAccountTokenRequest, null, 'application/json'); } /** @@ -530,7 +536,7 @@ export class AppStoreServerAPIClient { if (imageSize != null) { queryParameters["imageSize"] = [imageSize] } - await this.makeRequest("/inApps/v1/messaging/image/" + imageIdentifier, "PUT", queryParameters, image, null, 'image/png'); + await this.makeRequest("/inApps/v1/messaging/image/" + this.encodePathSegment(imageIdentifier), "PUT", queryParameters, image, null, 'image/png'); } /** @@ -541,7 +547,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/retentionmessaging/delete-image Delete Image} */ public async deleteImage(imageIdentifier: string): Promise { - await this.makeRequest("/inApps/v1/messaging/image/" + imageIdentifier, "DELETE", {}, null, null, undefined); + await this.makeRequest("/inApps/v1/messaging/image/" + this.encodePathSegment(imageIdentifier), "DELETE", {}, null, null, undefined); } /** @@ -564,7 +570,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/retentionmessaging/upload-message Upload Message} */ public async uploadMessage(messageIdentifier: string, uploadMessageRequestBody: UploadMessageRequestBody): Promise { - await this.makeRequest("/inApps/v1/messaging/message/" + messageIdentifier, "PUT", {}, uploadMessageRequestBody, null, 'application/json'); + await this.makeRequest("/inApps/v1/messaging/message/" + this.encodePathSegment(messageIdentifier), "PUT", {}, uploadMessageRequestBody, null, 'application/json'); } /** @@ -575,7 +581,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/retentionmessaging/delete-message Delete Message} */ public async deleteMessage(messageIdentifier: string): Promise { - await this.makeRequest("/inApps/v1/messaging/message/" + messageIdentifier, "DELETE", {}, null, null, undefined); + await this.makeRequest("/inApps/v1/messaging/message/" + this.encodePathSegment(messageIdentifier), "DELETE", {}, null, null, undefined); } /** @@ -599,7 +605,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/retentionmessaging/configure-default-message Configure Default Message} */ public async configureDefaultMessage(productId: string, locale: string, defaultConfigurationRequest: DefaultConfigurationRequest): Promise { - await this.makeRequest("/inApps/v1/messaging/default/" + productId + "/" + locale, "PUT", {}, defaultConfigurationRequest, null, 'application/json'); + await this.makeRequest("/inApps/v1/messaging/default/" + this.encodePathSegment(productId) + "/" + this.encodePathSegment(locale), "PUT", {}, defaultConfigurationRequest, null, 'application/json'); } /** @@ -611,7 +617,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/retentionmessaging/delete-default-message Delete Default Message} */ public async deleteDefaultMessage(productId: string, locale: string): Promise { - await this.makeRequest("/inApps/v1/messaging/default/" + productId + "/" + locale, "DELETE", {}, null, null, undefined); + await this.makeRequest("/inApps/v1/messaging/default/" + this.encodePathSegment(productId) + "/" + this.encodePathSegment(locale), "DELETE", {}, null, null, undefined); } /** @@ -624,7 +630,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/retentionmessaging/get-default-message Get Default Message} */ public async getDefaultMessage(productId: string, locale: string): Promise { - return await this.makeRequest("/inApps/v1/messaging/default/" + productId + "/" + locale, "GET", {}, null, new DefaultConfigurationResponseValidator(), undefined); + return await this.makeRequest("/inApps/v1/messaging/default/" + this.encodePathSegment(productId) + "/" + this.encodePathSegment(locale), "GET", {}, null, new DefaultConfigurationResponseValidator(), undefined); } /** @@ -680,7 +686,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/retentionmessaging/get-performance-test-results Get Performance Test Results} */ public async getPerformanceTestResults(requestId: string): Promise { - return await this.makeRequest("/inApps/v1/messaging/performanceTest/result/" + requestId, "GET", {}, null, new PerformanceTestResultResponseValidator(), undefined); + return await this.makeRequest("/inApps/v1/messaging/performanceTest/result/" + this.encodePathSegment(requestId), "GET", {}, null, new PerformanceTestResultResponseValidator(), undefined); } /** @@ -692,7 +698,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/get-app-transaction-info Get App Transaction Info} */ public async getAppTransactionInfo(anyTransactionId: string): Promise { - return await this.makeRequest("/inApps/v1/transactions/appTransactions/" + anyTransactionId, "GET", {}, null, new AppTransactionInfoResponseValidator(), undefined); + return await this.makeRequest("/inApps/v1/transactions/appTransactions/" + this.encodePathSegment(anyTransactionId), "GET", {}, null, new AppTransactionInfoResponseValidator(), undefined); } /** @@ -703,7 +709,7 @@ export class AppStoreServerAPIClient { * {@link https://developer.apple.com/documentation/appstoreserverapi/finish-transaction Finish Transaction} */ public async finishTransaction(transactionId: string): Promise { - await this.makeRequest("/inApps/v1/transactions/" + transactionId + "/finish", "POST", {}, null, null, undefined); + await this.makeRequest("/inApps/v1/transactions/" + this.encodePathSegment(transactionId) + "/finish", "POST", {}, null, null, undefined); } private createBearerToken(): string { diff --git a/tests/unit-tests/api_client.test.ts b/tests/unit-tests/api_client.test.ts index bb49d20..2792479 100644 --- a/tests/unit-tests/api_client.test.ts +++ b/tests/unit-tests/api_client.test.ts @@ -872,6 +872,54 @@ describe('The api client ', () => { expect(response.messageIdentifier).toBe("a1b2c3d4-e5f6-7890-a1b2-c3d4e5f67890") }) + it('encodes caller-controlled values as URL path segments', async () => { + async function assertPath(expectedPath: string, expectedMethod: string, invoke: (client: AppStoreServerAPIClient) => Promise) { + const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { + expect(expectedMethod).toBe(method) + expect(expectedPath).toBe(path) + expect(new URL("https://example.test" + path).pathname).toBe(expectedPath) + }) + + await invoke(client) + } + + await assertPath( + "/inApps/v1/messaging/default/product%2Fwith-slash/en-US", + "DELETE", + client => client.deleteDefaultMessage("product/with-slash", "en-US") + ) + await assertPath( + "/inApps/v1/messaging/message/message%3Fquery%23fragment", + "DELETE", + client => client.deleteMessage("message?query#fragment") + ) + await assertPath( + "/inApps/v1/messaging/image/image%2Fwith-slash", + "DELETE", + client => client.deleteImage("image/with-slash") + ) + await assertPath( + "/inApps/v1/messaging/image/%252E%252E", + "DELETE", + client => client.deleteImage("..") + ) + await assertPath( + "/inApps/v1/messaging/message/%252E", + "DELETE", + client => client.deleteMessage(".") + ) + await assertPath( + "/inApps/v1/transactions/id%3Fstatus%3D1%23fragment/finish", + "POST", + client => client.finishTransaction("id?status=1#fragment") + ) + await assertPath( + "/inApps/v1/messaging/default/com.example.product/en-US", + "DELETE", + client => client.deleteDefaultMessage("com.example.product", "en-US") + ) + }) + it('calls uploadImage with imageSize', async () => { const client = getAppStoreServerAPIClient("", 200, (path: string, parsedQueryParameters: URLSearchParams, method: string, requestBody: string | Buffer | undefined, headers: { [key: string]: string; }) => { expect("PUT").toBe(method) @@ -1103,4 +1151,4 @@ describe('The api client ', () => { await client.finishTransaction("1234"); }) -}) \ No newline at end of file +})