From f207d74467c94c64aeca67c39650b4fbc3a60464 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Thu, 23 Jul 2026 14:43:59 +0300 Subject: [PATCH 1/2] MOBILE-258: Allow path prefix for operaionsDomain --- .../OperationsDomainConfigPolicy.swift | 2 +- Mindbox/MBConfiguration.swift | 7 +- Mindbox/Network/Helpers/HostNormalizer.swift | 3 +- .../Network/Helpers/URLRequestBuilder.swift | 4 +- Mindbox/Validators/URLValidator.swift | 18 ++++ .../Configuration/MBConfigurationTests.swift | 49 ++++++++++ .../Network/OperationsURLRoutingTests.swift | 93 +++++++++++++++++++ 7 files changed, 170 insertions(+), 6 deletions(-) diff --git a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift index 960ad9b9..a5587a39 100644 --- a/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift +++ b/Mindbox/InAppMessages/Configuration/Services/OperationsDomainConfigPolicy.swift @@ -32,7 +32,7 @@ enum OperationsDomainConfigPolicy { return currentlyStored == nil ? .keep : .clear } - guard URLValidator.isValidHost(HostNormalizer.extractHost(value)) else { + guard URLValidator.isValidHostWithOptionalPath(HostNormalizer.extractHost(value)) else { return .rejected(value) } diff --git a/Mindbox/MBConfiguration.swift b/Mindbox/MBConfiguration.swift index ed73d63d..cfcdd868 100644 --- a/Mindbox/MBConfiguration.swift +++ b/Mindbox/MBConfiguration.swift @@ -27,8 +27,9 @@ public struct MBConfiguration: Codable { /// /// - Parameter endpoint: Used for app identification /// - Parameter domain: Used for generating baseurl for REST - /// - Parameter operationsDomain: Optional host for sending operations. Overridden by - /// the value from the mobile JSON config when present. Default `nil` (use `domain`). + /// - Parameter operationsDomain: Optional host for sending operations, optionally with + /// a path prefix (e.g. `domain.com/api/v2`) — operation endpoints are appended after it. + /// Overridden by the value from the mobile JSON config when present. Default `nil` (use `domain`). /// - Parameter previousInstallationId: Used to create tracking continuity by uuid /// - Parameter previousDeviceUUID: Used instead of the generated value /// - Parameter subscribeCustomerIfCreated: Flag which determines subscription status of the user. Default value is `false`. @@ -63,7 +64,7 @@ public struct MBConfiguration: Codable { } if let operationsDomain = operationsDomain, !operationsDomain.isEmpty { - guard URLValidator.isValidHost(HostNormalizer.extractHost(operationsDomain)) else { + guard URLValidator.isValidHostWithOptionalPath(HostNormalizer.extractHost(operationsDomain)) else { let error = MindboxError(.init(errorKey: .invalidConfiguration, reason: "Invalid operationsDomain. Host is unreachable. [OperationsDomain]: \(operationsDomain)")) Logger.error(error.asLoggerError()) throw error diff --git a/Mindbox/Network/Helpers/HostNormalizer.swift b/Mindbox/Network/Helpers/HostNormalizer.swift index 1dc2320b..9ef6f8e4 100644 --- a/Mindbox/Network/Helpers/HostNormalizer.swift +++ b/Mindbox/Network/Helpers/HostNormalizer.swift @@ -9,7 +9,8 @@ import Foundation /// Scheme-aware normalization for `domain` / `operationsDomain` inputs. -/// Accepts `host`, `https://host`, `http://host`, with or without trailing slash. +/// Accepts `host`, `https://host`, `http://host`, optionally followed by a path +/// prefix (`host/api/v2`), with or without trailing slash. The path is preserved. enum HostNormalizer { private static let httpsPrefix = "https://" diff --git a/Mindbox/Network/Helpers/URLRequestBuilder.swift b/Mindbox/Network/Helpers/URLRequestBuilder.swift index 60ba1262..455ee6bd 100644 --- a/Mindbox/Network/Helpers/URLRequestBuilder.swift +++ b/Mindbox/Network/Helpers/URLRequestBuilder.swift @@ -48,7 +48,9 @@ struct URLRequestBuilder { throw URLError(.badURL) } - components.path = route.path + // The base may carry a path prefix (anonymizer case: `host/api/mindbox-regular`); + // operation endpoints are appended after it, not instead of it. + components.path += route.path components.queryItems = makeQueryItems(for: route.queryParameters) return components diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index eb973f5c..61e3e9e4 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -20,6 +20,24 @@ enum URLValidator { /// RFC 1035: each label 1..63 chars. private static let maxLabelLength = 63 + /// Path-prefix rule for `isValidHostWithOptionalPath`: zero or more non-empty + /// segments of unreserved URL characters. Query, fragment and empty segments + /// do not match. + private static let pathPrefixPattern = "^(?:/[A-Za-z0-9._~%-]+)*$" + + /// Validates `host` or `host/path-prefix` (scheme must already be stripped, + /// e.g. via `HostNormalizer.extractHost`). Used for `operationsDomain`, which + /// may carry a path prefix (e.g. `domain.com/api/v2`) — operation endpoints + /// are appended after it. Path segments must be non-empty; query and fragment + /// are rejected. + static func isValidHostWithOptionalPath(_ value: String) -> Bool { + guard let slashIndex = value.firstIndex(of: "/") else { return isValidHost(value) } + let host = String(value[.. Bool { guard !host.isEmpty, host.count <= maxHostLength else { return false } diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index dc3425cc..0eb34108 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -135,6 +135,55 @@ struct MBConfigurationTests { } } + @Test("operationsDomain accepts path prefix (MOBILE-258)") + func operationsDomainAcceptsPathPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular" + ) + #expect(config.operationsDomain == "https://api-v2.letu.ru/api/mindbox-regular") + } + + @Test("operationsDomain accepts bare host with path prefix") + func operationsDomainAcceptsBareHostWithPathPrefix() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/api/v2" + ) + #expect(config.operationsDomain == "domain.com/api/v2") + } + + @Test("operationsDomain with query string throws") + func operationsDomainWithQueryThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/api?x=1" + ) + } + } + + @Test("operationsDomain with empty path segment throws") + func operationsDomainWithEmptyPathSegmentThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com//api" + ) + } + } + + @Test("domain with path prefix still throws — domain stays host-only") + func domainWithPathPrefixThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration(endpoint: endpoint, domain: "api.mindbox.ru/api/v2") + } + } + // MARK: - Init: previousInstallationId / previousDeviceUUID UUID handling @Test("Valid previousInstallationId UUID is stored") diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 230b9a85..9118782a 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -164,6 +164,57 @@ struct OperationsURLRoutingTests { #expect(url?.path == "/v3/operations/async") } + // MARK: - Path prefix in operationsDomain (MOBILE-258) + + @Test("operationsDomain with path prefix appends operation endpoint after the prefix") + func pathPrefixAppendsEndpointAfterPrefix() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "api-v2.letu.ru") + #expect(url?.path == "/api/mindbox-regular/v3/operations/async") + } + + @Test("Sync operations route keeps the path prefix") + func pathPrefixKeptOnSyncRoute() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + let syncWrapper = Self.makeEventWrapper(.syncEvent, bodyJSON: #"{"name":"X","payload":"{}"}"#) + + let url = try builder.asURLRequest(route: EventRoute.syncEvent(syncWrapper)).url + #expect(url?.path == "/api/mindbox-regular/v3/operations/sync") + } + + @Test("Bare host with path prefix gets default https:// and keeps the prefix") + func barePathPrefixUsesHttps() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "domain.com/api/v2") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.scheme == "https") + #expect(url?.host == "domain.com") + #expect(url?.path == "/api/v2/v3/operations/async") + } + + @Test("Trailing slash after path prefix does not produce a double slash") + func trailingSlashAfterPathPrefixStripped() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "domain.com/api/v2/") + let wrapper = Self.makeEventWrapper(.installed) + + let url = try builder.asURLRequest(route: EventRoute.asyncEvent(wrapper)).url + #expect(url?.path == "/api/v2/v3/operations/async") + } + + @Test("Config and geo routes are unaffected by operations path prefix") + func domainRoutesUnaffectedByPathPrefix() throws { + let builder = URLRequestBuilder(domain: domain, operationsDomain: "https://api-v2.letu.ru/api/mindbox-regular") + + let geoURL = try builder.asURLRequest(route: FetchInAppGeoRoute()).url + #expect(geoURL?.host == domain) + #expect(geoURL?.path == "/geo") + } + @Test("Fails fast when base URL is unparseable (no silent relative-URL request)") func failsFastOnUnparseableBaseURL() { // Embedded space defeats both `URLComponents(string:)` parsing and @@ -364,6 +415,48 @@ struct OperationsURLRoutingTests { ) } + @Test("Policy — saves value with path prefix in canonical form (MOBILE-258)") + func policySavesPathPrefixValue() { + #expect( + OperationsDomainConfigPolicy.action(for: "api-v2.letu.ru/api/mindbox-regular", currentlyStored: nil) + == .save("https://api-v2.letu.ru/api/mindbox-regular") + ) + } + + @Test("Policy — normalizes trailing slash after path prefix") + func policyNormalizesTrailingSlashAfterPathPrefix() { + #expect( + OperationsDomainConfigPolicy.action(for: "https://x.ru/api/v2/", currentlyStored: nil) + == .save("https://x.ru/api/v2") + ) + } + + @Test("Policy — keeps when canonical path-prefix form equals stored") + func policyKeepsOnMatchingPathPrefix() { + #expect( + OperationsDomainConfigPolicy.action( + for: "x.ru/api/v2/", + currentlyStored: "https://x.ru/api/v2" + ) == .keep + ) + } + + @Test("Policy — rejects path with query string") + func policyRejectsPathWithQuery() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/api?x=1", currentlyStored: "https://good.ru") + == .rejected("x.ru/api?x=1") + ) + } + + @Test("Policy — rejects empty path segment") + func policyRejectsEmptyPathSegment() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru//api", currentlyStored: "https://good.ru") + == .rejected("x.ru//api") + ) + } + // MARK: - Persistence lifecycle @Test("softReset preserves operationsDomainFromConfig (no PD leak on migration reset)") From e7b73123e749071d0b04cc8b8590008cc5cc7329 Mon Sep 17 00:00:00 2001 From: Egor Kitselyuk Date: Fri, 24 Jul 2026 10:45:22 +0300 Subject: [PATCH 2/2] MOBILE-258: Fix regex for operationDomain path --- Mindbox/Validators/URLValidator.swift | 9 +++-- .../Configuration/MBConfigurationTests.swift | 34 +++++++++++++++++++ .../Network/OperationsURLRoutingTests.swift | 26 ++++++++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/Mindbox/Validators/URLValidator.swift b/Mindbox/Validators/URLValidator.swift index 61e3e9e4..ef5ef168 100644 --- a/Mindbox/Validators/URLValidator.swift +++ b/Mindbox/Validators/URLValidator.swift @@ -21,9 +21,12 @@ enum URLValidator { private static let maxLabelLength = 63 /// Path-prefix rule for `isValidHostWithOptionalPath`: zero or more non-empty - /// segments of unreserved URL characters. Query, fragment and empty segments - /// do not match. - private static let pathPrefixPattern = "^(?:/[A-Za-z0-9._~%-]+)*$" + /// segments made of unreserved URL characters or complete `%XX` percent-encoded + /// octets. Query, fragment, empty segments, and a bare/incomplete `%` (not + /// followed by two hex digits) do not match — an incomplete escape parses fine + /// here but corrupts (or fails) `URLComponents` at request time, so it must be + /// rejected at validation, not discovered later as a runtime `badURL`. + private static let pathPrefixPattern = "^(?:/(?:[A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+)*$" /// Validates `host` or `host/path-prefix` (scheme must already be stripped, /// e.g. via `HostNormalizer.extractHost`). Used for `operationsDomain`, which diff --git a/MindboxTests/Configuration/MBConfigurationTests.swift b/MindboxTests/Configuration/MBConfigurationTests.swift index 0eb34108..4ff3b780 100644 --- a/MindboxTests/Configuration/MBConfigurationTests.swift +++ b/MindboxTests/Configuration/MBConfigurationTests.swift @@ -177,6 +177,40 @@ struct MBConfigurationTests { } } + @Test("operationsDomain accepts valid percent-encoded octet in path") + func operationsDomainAcceptsValidPercentEncoding() throws { + let config = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%20b" + ) + #expect(config.operationsDomain == "domain.com/a%20b") + } + + @Test("operationsDomain with non-hex percent escape throws") + func operationsDomainWithNonHexPercentEscapeThrows() { + // Regression: "%zz" parsed fine at validation but corrupted (or failed) + // URLComponents at request time — must be rejected here instead. + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%zz" + ) + } + } + + @Test("operationsDomain with dangling percent at end of path throws") + func operationsDomainWithDanglingPercentThrows() { + #expect(throws: MindboxError.self) { + _ = try MBConfiguration( + endpoint: endpoint, + domain: domain, + operationsDomain: "domain.com/a%" + ) + } + } + @Test("domain with path prefix still throws — domain stays host-only") func domainWithPathPrefixThrows() { #expect(throws: MindboxError.self) { diff --git a/MindboxTests/Network/OperationsURLRoutingTests.swift b/MindboxTests/Network/OperationsURLRoutingTests.swift index 9118782a..5156a17f 100644 --- a/MindboxTests/Network/OperationsURLRoutingTests.swift +++ b/MindboxTests/Network/OperationsURLRoutingTests.swift @@ -457,6 +457,32 @@ struct OperationsURLRoutingTests { ) } + @Test("Policy — accepts valid percent-encoded octet in path") + func policyAcceptsValidPercentEncoding() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%20b", currentlyStored: nil) + == .save("https://x.ru/a%20b") + ) + } + + @Test("Policy — rejects non-hex percent escape from config") + func policyRejectsNonHexPercentEscape() { + // Regression: "%zz" is not a valid percent-encoded octet — it would corrupt + // (or fail to build) the request URL at runtime if accepted here. + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%zz", currentlyStored: "https://good.ru") + == .rejected("x.ru/a%zz") + ) + } + + @Test("Policy — rejects dangling percent at end of path from config") + func policyRejectsDanglingPercent() { + #expect( + OperationsDomainConfigPolicy.action(for: "x.ru/a%", currentlyStored: "https://good.ru") + == .rejected("x.ru/a%") + ) + } + // MARK: - Persistence lifecycle @Test("softReset preserves operationsDomainFromConfig (no PD leak on migration reset)")