@scalebun/react-native 1.13.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +8 -0
- package/android/src/androidTest/java/com/scalebun/rn/ota/ScaleBunOtaVerifierInstrumentedTest.kt +112 -0
- package/android/src/main/java/com/scalebun/rn/ota/OtaProtocol.kt +244 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaKeyRegistry.kt +100 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +115 -60
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaReleaseVerifier.kt +129 -0
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +18 -0
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +4 -8
- package/dist/scalebun.full.js +127 -422
- package/dist/scalebun.slim.js +127 -422
- package/ios/Ota/OtaProtocol.swift +242 -0
- package/ios/Ota/OtaSlotManager.swift +26 -0
- package/ios/Ota/ScaleBunOtaBridge.mm +6 -3
- package/ios/Ota/ScaleBunOtaKeyRegistry.swift +147 -0
- package/ios/Ota/ScaleBunOtaModule.swift +125 -53
- package/ios/Ota/ScaleBunOtaReleaseVerifier.swift +115 -0
- package/ios/Ota/ScaleBunOtaVerifierTests.swift +92 -0
- package/lib/commonjs/core/config/schema.js +3 -12
- package/lib/commonjs/core/constants/version.js +1 -1
- package/lib/commonjs/features/network/NetworkFeature.js +39 -5
- package/lib/commonjs/features/network/thirdParty.js +90 -0
- package/lib/commonjs/features/ota/OtaOrchestrator.js +108 -60
- package/lib/commonjs/public/ScaleBunFacade.js +5 -41
- package/lib/module/core/config/schema.js +3 -12
- package/lib/module/core/constants/version.js +1 -1
- package/lib/module/features/network/NetworkFeature.js +38 -4
- package/lib/module/features/network/thirdParty.js +83 -0
- package/lib/module/features/ota/OtaOrchestrator.js +108 -60
- package/lib/module/public/ScaleBunFacade.js +5 -41
- package/lib/typescript/core/config/schema.d.ts +0 -3
- package/lib/typescript/core/constants/version.d.ts +1 -1
- package/lib/typescript/features/network/index.d.ts +18 -0
- package/lib/typescript/features/network/thirdParty.d.ts +65 -0
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +5 -8
- package/lib/typescript/features/ota/OtaTypes.d.ts +22 -0
- package/lib/typescript/public/ScaleBunFacade.d.ts +5 -11
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +30 -20
- package/package.json +3 -13
- package/scalebun-react-native.podspec +4 -0
- package/src/core/config/schema.ts +3 -9
- package/src/core/constants/version.ts +1 -1
- package/src/features/network/NetworkFeature.ts +41 -4
- package/src/features/network/index.ts +18 -0
- package/src/features/network/thirdParty.ts +92 -0
- package/src/features/ota/OtaOrchestrator.ts +116 -70
- package/src/features/ota/OtaTypes.ts +23 -1
- package/src/public/ScaleBunFacade.ts +5 -53
- package/src/specs/NativeScaleBunOta.ts +32 -20
- package/lib/commonjs/features/ota/crypto/builtinVerifier.js +0 -248
- package/lib/commonjs/features/ota/crypto/loadEd25519.js +0 -40
- package/lib/commonjs/features/ota/crypto/loadSha512.js +0 -40
- package/lib/commonjs/features/ota/crypto/nativeVerifier.js +0 -121
- package/lib/commonjs/features/ota/signature.js +0 -175
- package/lib/module/features/ota/crypto/builtinVerifier.js +0 -240
- package/lib/module/features/ota/crypto/loadEd25519.js +0 -34
- package/lib/module/features/ota/crypto/loadSha512.js +0 -34
- package/lib/module/features/ota/crypto/nativeVerifier.js +0 -113
- package/lib/module/features/ota/signature.js +0 -169
- package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +0 -53
- package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +0 -30
- package/lib/typescript/features/ota/crypto/loadSha512.d.ts +0 -15
- package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +0 -35
- package/lib/typescript/features/ota/signature.d.ts +0 -81
- package/src/features/ota/crypto/builtinVerifier.ts +0 -257
- package/src/features/ota/crypto/loadEd25519.ts +0 -41
- package/src/features/ota/crypto/loadSha512.ts +0 -35
- package/src/features/ota/crypto/nativeVerifier.ts +0 -117
- package/src/features/ota/signature.ts +0 -206
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import React
|
|
3
3
|
import CommonCrypto
|
|
4
|
-
import CryptoKit
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* React Native bridge for OTA bundle management (`ScaleBunOta`) on iOS.
|
|
@@ -21,6 +20,21 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
|
|
|
21
20
|
|
|
22
21
|
private static let slotManager = OtaSlotManager()
|
|
23
22
|
|
|
23
|
+
/// Trusted RSA public keys embedded in the app bundle (docs §9). Non-empty
|
|
24
|
+
/// once the app ships a `ScaleBunOtaKeys.json` resource — which flips staging
|
|
25
|
+
/// into the native-ENFORCED path (see `stageBundle`).
|
|
26
|
+
private static let keyRegistry = ScaleBunOtaKeyRegistry.fromBundle()
|
|
27
|
+
|
|
28
|
+
/// Device-derived verification context — never taken from JS.
|
|
29
|
+
private static func buildContext() -> ExpectedContext {
|
|
30
|
+
ExpectedContext(
|
|
31
|
+
projectId: keyRegistry.projectId,
|
|
32
|
+
applicationId: Bundle.main.bundleIdentifier ?? "",
|
|
33
|
+
currentSequence: slotManager.highestConfirmedSequence(),
|
|
34
|
+
nowEpochSeconds: Int64(Date().timeIntervalSince1970)
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
static func moduleName() -> String! { "ScaleBunOta" }
|
|
25
39
|
|
|
26
40
|
@objc static func requiresMainQueueSetup() -> Bool { false }
|
|
@@ -73,6 +87,17 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
|
|
|
73
87
|
return ScaleBunOtaModule.slotManager.slotStateJSON() as NSString
|
|
74
88
|
}
|
|
75
89
|
|
|
90
|
+
/// JSON array of the trusted key IDs embedded in this app (SDK trust coverage).
|
|
91
|
+
@objc(getTrustedKeyIds)
|
|
92
|
+
func getTrustedKeyIds() -> NSString {
|
|
93
|
+
let ids = ScaleBunOtaModule.keyRegistry.keyIds()
|
|
94
|
+
if let data = try? JSONSerialization.data(withJSONObject: ids),
|
|
95
|
+
let s = String(data: data, encoding: .utf8) {
|
|
96
|
+
return s as NSString
|
|
97
|
+
}
|
|
98
|
+
return "[]"
|
|
99
|
+
}
|
|
100
|
+
|
|
76
101
|
@objc(stageBundle:sha256:resolve:reject:)
|
|
77
102
|
/// Stage a bundle into the `next` slot.
|
|
78
103
|
///
|
|
@@ -85,6 +110,16 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
|
|
|
85
110
|
sha256: NSString,
|
|
86
111
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
87
112
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
113
|
+
// Native-enforced staging (docs §17): once trusted keys are embedded, the
|
|
114
|
+
// ONLY way to stage is verifyAndStageRelease, which proves an RSA signature
|
|
115
|
+
// over the release payload first. SHA-256 integrity alone (what this path
|
|
116
|
+
// checks) is not authenticity, so a hostile JS bundle cannot use it to
|
|
117
|
+
// drive an unauthenticated bundle onto the device.
|
|
118
|
+
if !ScaleBunOtaModule.keyRegistry.isEmpty {
|
|
119
|
+
NSLog("[ScaleBunOta] stageBundle refused: trusted keys embedded — use verifyAndStageRelease")
|
|
120
|
+
resolve(false)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
88
123
|
let source = sourcePath as String
|
|
89
124
|
let meta: [String: Any] = [
|
|
90
125
|
"sha256": sha256 as String,
|
|
@@ -140,6 +175,16 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
|
|
|
140
175
|
expectedSha256: NSString,
|
|
141
176
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
142
177
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
178
|
+
// Same native-enforcement as stageBundle: a bsdiff patch staged by hash
|
|
179
|
+
// alone carries no signature, so once trusted keys are embedded this path
|
|
180
|
+
// is closed. Result-anchored signed patches are a native follow-up; until
|
|
181
|
+
// then the SDK falls back to the verified full download. false = "patch
|
|
182
|
+
// unavailable".
|
|
183
|
+
if !ScaleBunOtaModule.keyRegistry.isEmpty {
|
|
184
|
+
NSLog("[ScaleBunOta] stagePatch disabled while trusted keys are embedded — full verified download")
|
|
185
|
+
resolve(false)
|
|
186
|
+
return
|
|
187
|
+
}
|
|
143
188
|
// Patching is relative to the bundle already on disk. With no OTA bundle
|
|
144
189
|
// installed the source would be the app's built-in bundle, which the
|
|
145
190
|
// server did not diff against — so a full download is the correct path.
|
|
@@ -255,68 +300,95 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
|
|
|
255
300
|
}
|
|
256
301
|
|
|
257
302
|
/**
|
|
258
|
-
* Verify a
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
* Contract (mirrors `src/specs/NativeScaleBunOta.ts`): inputs are
|
|
267
|
-
* lowercase hex pre-validated by the JS caller; the message is the RAW 32
|
|
268
|
-
* BYTES of the bundle SHA-256 (the CLI signs digest bytes, not hex text);
|
|
269
|
-
* resolves 'valid' / 'invalid' / 'unavailable'. A wrong signature is
|
|
270
|
-
* 'invalid', never a rejection; anything unexpected is 'unavailable' so
|
|
271
|
-
* machinery failure can never read as a pass.
|
|
303
|
+
* Verify a SB-OTA-RSA-SHA256-V1 release envelope with PLATFORM crypto
|
|
304
|
+
* (`SecKeyVerifySignature`) and the EMBEDDED trusted-key registry — no
|
|
305
|
+
* download, no side effects. Resolves the verdict JSON (`{ ok, code?, keyId?,
|
|
306
|
+
* releaseId?, bundleId?, releaseSequence?, artifactSha256?, artifactSize?,
|
|
307
|
+
* resultArtifactSha256?, fingerprint? }`). A failed check is `ok:false` with a
|
|
308
|
+
* stable `code`, never a rejected promise. Native because the JS verifier
|
|
309
|
+
* lives inside the very bundle it protects, and the trusted key must come from
|
|
310
|
+
* the app bundle, not from JS or the server.
|
|
272
311
|
*
|
|
273
|
-
* ⚪
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
* this file still needs one Xcode build before an iOS consumer ships it.
|
|
312
|
+
* ⚪ Not compiled in this workspace (no macOS toolchain). The RSA/SHA-256
|
|
313
|
+
* algorithm + wire format are gated by tools/rsa-ota-parity against the shared
|
|
314
|
+
* fixtures, and ScaleBunOtaVerifierTests is provided to run on a simulator.
|
|
277
315
|
*/
|
|
278
|
-
@objc(
|
|
279
|
-
func
|
|
280
|
-
signatureHex: NSString,
|
|
281
|
-
publicKeyHex: NSString,
|
|
316
|
+
@objc(verifyRelease:resolve:reject:)
|
|
317
|
+
func verifyRelease(_ envelopeJson: NSString,
|
|
282
318
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
283
319
|
reject: @escaping RCTPromiseRejectBlock) {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
320
|
+
let verdict = ScaleBunOtaReleaseVerifier.verify(
|
|
321
|
+
envelopeJson: envelopeJson as String,
|
|
322
|
+
expected: ScaleBunOtaModule.buildContext(),
|
|
323
|
+
registry: ScaleBunOtaModule.keyRegistry
|
|
324
|
+
)
|
|
325
|
+
resolve(verdict.toJson())
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Native-ENFORCED install (docs §5, §17): verify the RSA signature against an
|
|
330
|
+
* embedded key, THEN download, enforce the SIGNED size + SHA-256, and stage
|
|
331
|
+
* into the `next` slot. JS never stages bytes that were not proven authentic
|
|
332
|
+
* (once keys are embedded, `stageBundle` refuses). Does not activate —
|
|
333
|
+
* `applyUpdate` promotes per the install mode. Resolves the verdict JSON.
|
|
334
|
+
*/
|
|
335
|
+
@objc(verifyAndStageRelease:resolve:reject:)
|
|
336
|
+
func verifyAndStageRelease(_ envelopeJson: NSString,
|
|
337
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
338
|
+
reject: @escaping RCTPromiseRejectBlock) {
|
|
339
|
+
let verdict = ScaleBunOtaReleaseVerifier.verify(
|
|
340
|
+
envelopeJson: envelopeJson as String,
|
|
341
|
+
expected: ScaleBunOtaModule.buildContext(),
|
|
342
|
+
registry: ScaleBunOtaModule.keyRegistry
|
|
343
|
+
)
|
|
344
|
+
guard verdict.ok, let payload = verdict.payload else {
|
|
345
|
+
resolve(verdict.toJson())
|
|
290
346
|
return
|
|
291
347
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
348
|
+
|
|
349
|
+
guard let data = (envelopeJson as String).data(using: .utf8),
|
|
350
|
+
let env = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
351
|
+
let url = env["downloadUrl"] as? String, !url.isEmpty else {
|
|
352
|
+
resolve(Verdict.fail(.DOWNLOAD_FAILED, "envelope has no downloadUrl").toJson())
|
|
353
|
+
return
|
|
298
354
|
}
|
|
299
|
-
}
|
|
300
355
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
356
|
+
let temp = FileManager.default.temporaryDirectory
|
|
357
|
+
.appendingPathComponent("scalebun_ota_dl_\(UUID().uuidString)")
|
|
358
|
+
defer { try? FileManager.default.removeItem(at: temp) }
|
|
359
|
+
|
|
360
|
+
guard BundleDownloader.download(urlString: url, destination: temp) else {
|
|
361
|
+
resolve(Verdict.fail(.DOWNLOAD_FAILED, "download failed").toJson())
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
// Size first (cheap) against the SIGNED artifactSize.
|
|
365
|
+
let attrs = try? FileManager.default.attributesOfItem(atPath: temp.path)
|
|
366
|
+
let fileSize = (attrs?[.size] as? NSNumber)?.int64Value ?? -1
|
|
367
|
+
if fileSize != payload.artifactSize {
|
|
368
|
+
resolve(Verdict.fail(.ARTIFACT_SIZE_MISMATCH, "downloaded size mismatch").toJson())
|
|
369
|
+
return
|
|
310
370
|
}
|
|
311
|
-
return out
|
|
312
|
-
}
|
|
313
371
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
372
|
+
// The slot manager streams a SHA-256 and compares against the signed
|
|
373
|
+
// artifactSha256 (== resultArtifactSha256 for a full artifact), so a wrong
|
|
374
|
+
// download cannot be staged. meta carries the sequence used by the
|
|
375
|
+
// anti-downgrade floor once the bundle is confirmed healthy.
|
|
376
|
+
let meta: [String: Any] = [
|
|
377
|
+
"sha256": payload.artifactSha256,
|
|
378
|
+
"releaseSequence": payload.releaseSequence,
|
|
379
|
+
"bundleId": payload.bundleId,
|
|
380
|
+
"releaseId": payload.releaseId,
|
|
381
|
+
"installedAt": Int(Date().timeIntervalSince1970 * 1000)
|
|
382
|
+
]
|
|
383
|
+
let staged = ScaleBunOtaModule.slotManager.stage(
|
|
384
|
+
sourcePath: temp.path,
|
|
385
|
+
expectedSha256: payload.artifactSha256,
|
|
386
|
+
meta: meta
|
|
387
|
+
)
|
|
388
|
+
if !staged {
|
|
389
|
+
resolve(Verdict.fail(.ARTIFACT_HASH_MISMATCH, "staged bytes failed hash check").toJson())
|
|
390
|
+
return
|
|
320
391
|
}
|
|
392
|
+
resolve(verdict.toJson())
|
|
321
393
|
}
|
|
322
394
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SB-OTA-RSA-SHA256-V1 native release verifier for iOS (docs §5) — the mirror of
|
|
6
|
+
* Android's ScaleBunOtaReleaseVerifier. Runs the authoritative order: strict
|
|
7
|
+
* decode → registry-only key resolution → `SecKeyVerifySignature` with
|
|
8
|
+
* `.rsaSignatureMessagePKCS1v15SHA256` over the EXACT signed-payload bytes →
|
|
9
|
+
* parse → cross-check against the device's own identity + the anti-downgrade
|
|
10
|
+
* floor. Fails closed and maps every failure to a stable OtaFailureCode.
|
|
11
|
+
*
|
|
12
|
+
* The message-level algorithm hashes the payload internally, so the payload is
|
|
13
|
+
* passed RAW (never pre-hashed — pre-hashing then using a message-level API would
|
|
14
|
+
* hash twice, PROMPT §11.2).
|
|
15
|
+
*/
|
|
16
|
+
struct ExpectedContext {
|
|
17
|
+
let projectId: String
|
|
18
|
+
let applicationId: String // the app's own bundle identifier
|
|
19
|
+
let currentSequence: Int64 // anti-downgrade floor
|
|
20
|
+
let nowEpochSeconds: Int64
|
|
21
|
+
var platform: String = "ios"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
struct Verdict {
|
|
25
|
+
let ok: Bool
|
|
26
|
+
let code: OtaFailureCode?
|
|
27
|
+
let message: String?
|
|
28
|
+
let payload: SignedRelease?
|
|
29
|
+
let fingerprint: String?
|
|
30
|
+
|
|
31
|
+
static func fail(_ code: OtaFailureCode, _ message: String) -> Verdict {
|
|
32
|
+
Verdict(ok: false, code: code, message: message, payload: nil, fingerprint: nil)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func toJson() -> String {
|
|
36
|
+
var o: [String: Any] = ["ok": ok]
|
|
37
|
+
if let c = code { o["code"] = c.rawValue }
|
|
38
|
+
if let m = message { o["message"] = m }
|
|
39
|
+
if let f = fingerprint { o["fingerprint"] = f }
|
|
40
|
+
if let p = payload {
|
|
41
|
+
o["keyId"] = p.keyId
|
|
42
|
+
o["releaseId"] = p.releaseId
|
|
43
|
+
o["bundleId"] = p.bundleId
|
|
44
|
+
o["releaseSequence"] = p.releaseSequence
|
|
45
|
+
o["artifactSha256"] = p.artifactSha256
|
|
46
|
+
o["artifactSize"] = p.artifactSize
|
|
47
|
+
o["resultArtifactSha256"] = p.resultArtifactSha256
|
|
48
|
+
o["artifactType"] = p.artifactType
|
|
49
|
+
}
|
|
50
|
+
guard let data = try? JSONSerialization.data(withJSONObject: o),
|
|
51
|
+
let s = String(data: data, encoding: .utf8) else { return "{\"ok\":false,\"code\":\"INTERNAL_VERIFICATION_ERROR\"}" }
|
|
52
|
+
return s
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
enum ScaleBunOtaReleaseVerifier {
|
|
57
|
+
|
|
58
|
+
static func verify(envelopeJson: String, expected: ExpectedContext, registry: ScaleBunOtaKeyRegistry) -> Verdict {
|
|
59
|
+
do {
|
|
60
|
+
guard let data = envelopeJson.data(using: .utf8),
|
|
61
|
+
let env = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
62
|
+
return .fail(.INVALID_ENVELOPE, "envelope is not JSON")
|
|
63
|
+
}
|
|
64
|
+
let proto = (env["protocol"] as? String) ?? ""
|
|
65
|
+
if proto != OtaProtocol.signatureAlgorithm { return .fail(.UNSUPPORTED_PROTOCOL, "protocol \(proto)") }
|
|
66
|
+
let signedPayloadB64 = (env["signedPayload"] as? String) ?? ""
|
|
67
|
+
let signatureB64 = (env["signature"] as? String) ?? ""
|
|
68
|
+
let envKeyId = (env["keyId"] as? String) ?? ""
|
|
69
|
+
|
|
70
|
+
let payloadBytes = try OtaProtocol.decodeBase64Strict(signedPayloadB64, OtaProtocol.maxSignedPayloadBytes, "signedPayload")
|
|
71
|
+
let signature = try OtaProtocol.decodeBase64Strict(signatureB64, OtaProtocol.maxSignatureBytes, "signature")
|
|
72
|
+
|
|
73
|
+
let payloadKeyId = try OtaProtocol.peekKeyId(payloadBytes)
|
|
74
|
+
if !envKeyId.isEmpty && envKeyId != payloadKeyId {
|
|
75
|
+
return .fail(.INVALID_ENVELOPE, "envelope keyId disagrees with payload")
|
|
76
|
+
}
|
|
77
|
+
guard let key = registry.get(payloadKeyId) else {
|
|
78
|
+
return .fail(.UNKNOWN_KEY_ID, "no embedded key with id \(payloadKeyId)")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// RSA-SHA256 over the EXACT payload bytes, BEFORE parsing.
|
|
82
|
+
var error: Unmanaged<CFError>?
|
|
83
|
+
let valid = SecKeyVerifySignature(
|
|
84
|
+
key.secKey,
|
|
85
|
+
.rsaSignatureMessagePKCS1v15SHA256,
|
|
86
|
+
Data(payloadBytes) as CFData,
|
|
87
|
+
Data(signature) as CFData,
|
|
88
|
+
&error
|
|
89
|
+
)
|
|
90
|
+
if !valid { return .fail(.INVALID_SIGNATURE, "signature did not verify") }
|
|
91
|
+
|
|
92
|
+
let payload = try OtaProtocol.parse(payloadBytes)
|
|
93
|
+
|
|
94
|
+
if payload.artifactType != "full" { return .fail(.INVALID_SIGNED_PAYLOAD, "signed payload is not a full release") }
|
|
95
|
+
let projectExpected = registry.projectId.isEmpty ? expected.projectId : registry.projectId
|
|
96
|
+
if !projectExpected.isEmpty && payload.projectId != projectExpected { return .fail(.PROJECT_MISMATCH, "projectId") }
|
|
97
|
+
if !expected.applicationId.isEmpty && payload.applicationId != expected.applicationId {
|
|
98
|
+
return .fail(.APPLICATION_MISMATCH, "applicationId")
|
|
99
|
+
}
|
|
100
|
+
if payload.platform != expected.platform { return .fail(.PLATFORM_MISMATCH, "platform") }
|
|
101
|
+
if payload.expiresAtEpochSeconds != 0 && expected.nowEpochSeconds > payload.expiresAtEpochSeconds {
|
|
102
|
+
return .fail(.RELEASE_EXPIRED, "release expired")
|
|
103
|
+
}
|
|
104
|
+
if payload.releaseSequence < expected.currentSequence {
|
|
105
|
+
return .fail(.RELEASE_DOWNGRADE_REJECTED, "sequence below current")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return Verdict(ok: true, code: nil, message: nil, payload: payload, fingerprint: key.fingerprint)
|
|
109
|
+
} catch let e as OtaVerifyError {
|
|
110
|
+
return .fail(e.code, e.message)
|
|
111
|
+
} catch {
|
|
112
|
+
return .fail(.INTERNAL_VERIFICATION_ERROR, "\(error)")
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import scalebun_react_native
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* On-device / simulator verification for SB-OTA-RSA-SHA256-V1 (PROMPT §23).
|
|
6
|
+
*
|
|
7
|
+
* Proves the shared committed vector (packages/ota-protocol/fixtures — the SAME
|
|
8
|
+
* bytes the Node codec, backend, JVM harness, and Android test use) verifies with
|
|
9
|
+
* `SecKeyVerifySignature(.rsaSignatureMessagePKCS1v15SHA256)` and the SPKI→PKCS#1
|
|
10
|
+
* import path, and that tampering / unknown keys / downgrades are rejected.
|
|
11
|
+
*
|
|
12
|
+
* ⚪ Not run in this workspace (no macOS toolchain). Add this file to the host
|
|
13
|
+
* app's iOS unit-test target and run: xcodebuild test -scheme <App> -destination
|
|
14
|
+
* 'platform=iOS Simulator,name=iPhone 15'. Constants are copied verbatim from
|
|
15
|
+
* fixtures/vectors/full.json.
|
|
16
|
+
*/
|
|
17
|
+
final class ScaleBunOtaVerifierTests: XCTestCase {
|
|
18
|
+
|
|
19
|
+
private let signedPayloadB64 =
|
|
20
|
+
"U0JPVEEwMDEAAQAAABRTQi1PVEEtUlNBLVNIQTI1Ni1WMQAAAAdTSEEtMjU2AAAAFXRlc3QtcnNhLTMwNzItMjAyNi0wMQAAAA5wcm9qX3Rlc3RfMDAwMQAAABVvcmcuc2NhbGVidW4ubGVnYXJhZ2UAAAANcmVsX2Z1bGxfMDAwMQAAAA5ibmRsX2Z1bGxfMDAwMQAAAAdhbmRyb2lkAAAABGZ1bGwAAAAFNC41LjkAAAAGMC43NC4xAAAAAjI0AAAAAAAAACoAAAAAaVW5AAAAAAAAAAAAAAAAAAAAAWD6bpV7WCc0zBI9NJ6rivyixtOu66jZ7EYSJniR99yQMwAAAAAAAAAA+m6Ve1gnNMwSPTSeq4r8osbTruuo2exGEiZ4kffckDM="
|
|
21
|
+
private let signatureB64 =
|
|
22
|
+
"jlC7GRLWKXku3cbqtODlFt+qn2CEmS3oHOr9QTopCCzEovUF5a2GRT0PQ5k2C5EX7UGGO0Sv6/WhkznKR9NVEZl6ed0KARHAY8zfwTjin3czx9jpwV9bfsLlxEoKNo9bPfIs3mc0VoHCWOp95hQAotG+GqEwYsXqPruZ43U87qWT0t9/a3QboX+07hPO5vI4fDZQuPeo3W1HmXPKbOB7QGSijtKgKwQNuJHz+etsVsQelDsDofRSpYV92XCheUWWHP7kZKFOCcYUXnVwJcdU0Jhej+22vAESRK2G0CXP3orXE4pASaA3y4wkBQgJAJkcTN/ic1/Wm5BLo7KH+ypK1eo0hnrrLNSh6E+St5NvxlTM3bmxwnwHkPMYVBuOT0fUJy+gbD1/1CPvKakL12vizKwWDYuhfcIGkQUTvP2zCxcqWLfmZ7ZGo6n6WXMX63Vz+IQG0tnx1cp6YqJVIgxqjskQ47sacsfS9QeV5LyJhvBQrDSbpqujo9la8dDzZfDN"
|
|
23
|
+
private let keyId = "test-rsa-3072-2026-01"
|
|
24
|
+
private let projectId = "proj_test_0001"
|
|
25
|
+
private let applicationId = "org.scalebun.legarage"
|
|
26
|
+
|
|
27
|
+
private let spkiPem = """
|
|
28
|
+
-----BEGIN PUBLIC KEY-----
|
|
29
|
+
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAt3tfFTPE+lkEbFeYi6zE
|
|
30
|
+
+uI+3oJl0HfKtGTh7sr7pSh/uPJq2giF0Uc09ux3Z0i4NlqpOyLmiIrVOgYcWcN+
|
|
31
|
+
/pCl1mh6VmT7nYO3sEhL+HyaIaclCGxvHCJSsaVHH8qQyiCVUypWWKN0L38U4isN
|
|
32
|
+
dJ2x+Wv7iLSZu3oM+ZDyGWCjRLE49jKNl0tfaQX6G1YoyBi1aEu5sWIQ6LjGE4IX
|
|
33
|
+
1AJtOiNoaO9orjVgphA5FDKFFqguhEJrHoSXt3awHaeCYFm/mt+NI6k4sRf59UNx
|
|
34
|
+
hdGh0kVL/6jZncclxueFafOOdfPf+jF3ylTczQhnID7Mg1PdmjXR7cfq7jDdjzfW
|
|
35
|
+
VICJqGt+EhUd92pWrMTa5k3SDin3EetYGJsN7iKLbAl0D6Uy8RfE2mdsF2K6D3EP
|
|
36
|
+
9waHN7vLURCF//TGy84FxPy7vbFgLO4oT07lxaopuGX7vOdONI8qLcFhEAJ9fRVN
|
|
37
|
+
lqPWQGib0iAKE+gLl4CWZen674QZF39fGnOBEz7JsmDfAgMBAAE=
|
|
38
|
+
-----END PUBLIC KEY-----
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
private func registry() throws -> ScaleBunOtaKeyRegistry {
|
|
42
|
+
let json = "{\"projectId\":\"\(projectId)\",\"keys\":[{\"keyId\":\"\(keyId)\",\"publicKey\":\"\(spkiPem.replacingOccurrences(of: "\n", with: "\\n"))\"}]}"
|
|
43
|
+
return try ScaleBunOtaKeyRegistry.fromJson(json)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private func context(_ currentSequence: Int64 = 0) -> ExpectedContext {
|
|
47
|
+
ExpectedContext(projectId: projectId, applicationId: applicationId,
|
|
48
|
+
currentSequence: currentSequence, nowEpochSeconds: 1_767_225_660)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private func envelope(payload: String? = nil, sig: String? = nil) -> String {
|
|
52
|
+
"{\"protocol\":\"\(OtaProtocol.signatureAlgorithm)\",\"keyId\":\"\(keyId)\"," +
|
|
53
|
+
"\"signedPayload\":\"\(payload ?? signedPayloadB64)\",\"signature\":\"\(sig ?? signatureB64)\"}"
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func testGenuineReleaseVerifies() throws {
|
|
57
|
+
let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(), expected: context(), registry: try registry())
|
|
58
|
+
XCTAssertTrue(v.ok, "expected ok, got \(String(describing: v.code))")
|
|
59
|
+
XCTAssertEqual(v.payload?.bundleId, "bndl_full_0001")
|
|
60
|
+
XCTAssertEqual(v.payload?.releaseSequence, 42)
|
|
61
|
+
XCTAssertEqual(v.payload?.platform, "android") // vector is an android full release
|
|
62
|
+
XCTAssertEqual(v.payload?.artifactSha256, v.payload?.resultArtifactSha256)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
func testPlatformMismatchRejected() throws {
|
|
66
|
+
// Device context is iOS by default; the vector payload is android.
|
|
67
|
+
var ctx = context(); ctx.platform = "ios"
|
|
68
|
+
let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(), expected: ctx, registry: try registry())
|
|
69
|
+
XCTAssertEqual(v.code, .PLATFORM_MISMATCH)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func testTamperedPayloadRejected() throws {
|
|
73
|
+
let bad = String(signedPayloadB64.dropLast(2)) + (signedPayloadB64.hasSuffix("M=") ? "N=" : "M=")
|
|
74
|
+
var ctx = context(); ctx.platform = "android"
|
|
75
|
+
let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(payload: bad), expected: ctx, registry: try registry())
|
|
76
|
+
XCTAssertFalse(v.ok)
|
|
77
|
+
XCTAssertTrue(v.code == .INVALID_SIGNATURE || v.code == .INVALID_BASE64)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func testUnknownKeyRejected() throws {
|
|
81
|
+
let empty = try ScaleBunOtaKeyRegistry.fromJson("{\"projectId\":\"\(projectId)\",\"keys\":[]}")
|
|
82
|
+
var ctx = context(); ctx.platform = "android"
|
|
83
|
+
let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(), expected: ctx, registry: empty)
|
|
84
|
+
XCTAssertEqual(v.code, .UNKNOWN_KEY_ID)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func testDowngradeRejected() throws {
|
|
88
|
+
var ctx = context(100); ctx.platform = "android"
|
|
89
|
+
let v = ScaleBunOtaReleaseVerifier.verify(envelopeJson: envelope(), expected: ctx, registry: try registry())
|
|
90
|
+
XCTAssertEqual(v.code, .RELEASE_DOWNGRADE_REJECTED)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -291,18 +291,9 @@ const SHAPE = {
|
|
|
291
291
|
kind: 'str',
|
|
292
292
|
opt: true
|
|
293
293
|
},
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
},
|
|
298
|
-
// Rotation support: several pinned keys, any of which may verify a
|
|
299
|
-
// bundle. Ship a build pinning [old, new], re-sign server-side with
|
|
300
|
-
// new, drop old next release — no emergency store submission when a
|
|
301
|
-
// key must be replaced. Merged with publicSigningKey by the facade.
|
|
302
|
-
publicSigningKeys: {
|
|
303
|
-
kind: 'strArr',
|
|
304
|
-
opt: true
|
|
305
|
-
},
|
|
294
|
+
// No signing-key fields: signed releases are verified natively against
|
|
295
|
+
// RSA keys embedded in the app package (SB-OTA-RSA-SHA256-V1). JS must
|
|
296
|
+
// not supply a trusted key.
|
|
306
297
|
mandatoryBlocksUi: bool(false)
|
|
307
298
|
}
|
|
308
299
|
}
|
|
@@ -14,5 +14,5 @@ exports.SDK_VERSION = void 0;
|
|
|
14
14
|
* value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
|
|
15
15
|
* version was introduced to solve.
|
|
16
16
|
*/
|
|
17
|
-
const SDK_VERSION = exports.SDK_VERSION = '
|
|
17
|
+
const SDK_VERSION = exports.SDK_VERSION = '2.0.0';
|
|
18
18
|
//# sourceMappingURL=version.js.map
|
|
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.NetworkFeature = void 0;
|
|
7
7
|
var _index = require("./index");
|
|
8
|
+
var _thirdParty = require("./thirdParty");
|
|
8
9
|
var _networkAdapter = require("../replay/integrations/network/networkAdapter");
|
|
9
10
|
var _SessionManager = require("../session/SessionManager");
|
|
10
11
|
var _screenTracking = require("../../debug/screenTracking");
|
|
@@ -50,12 +51,28 @@ class NetworkFeature {
|
|
|
50
51
|
// self-capture loop. Apply the SAME isSdkInternalUrl filter the replay
|
|
51
52
|
// branch below already uses so genuine app requests still flow through.
|
|
52
53
|
const isInternal = isSdkInternalUrl(data.url, this.options.sdkBaseUrl);
|
|
53
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Beacons and denied URLs leave NO trace, in any lane.
|
|
56
|
+
*
|
|
57
|
+
* A network request reaches three places from here: the analytics echo
|
|
58
|
+
* (`$network_request`), the local replay pipeline, and the backend network endpoint
|
|
59
|
+
* that becomes the `network_requests` row the session panel renders. Filtering one
|
|
60
|
+
* and not the others is how a request half-disappears and two counts of the same
|
|
61
|
+
* call stop agreeing -- so this is applied to all three.
|
|
62
|
+
*
|
|
63
|
+
* Kept separate from `isInternal` rather than folded into it: the SDK's own uploads
|
|
64
|
+
* are already excluded from two of the three lanes and DELIBERATELY still reach the
|
|
65
|
+
* local replay pipeline. Widening that here would be an unrelated behaviour change
|
|
66
|
+
* riding along on a filter.
|
|
67
|
+
*/
|
|
68
|
+
const isBeaconOrDenied = this.options.captureThirdParty !== true && (0, _thirdParty.isThirdPartyBeacon)(data.url, this.options.thirdPartyHosts) || matchesDenyUrl(data.url, this.options.denyUrls);
|
|
69
|
+
const isExcluded = isInternal || isBeaconOrDenied;
|
|
70
|
+
if (!isExcluded) {
|
|
54
71
|
this.trackFn('$network_request', data);
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
// Bridge the raw network event into the Session Replay pipeline
|
|
58
|
-
if (data.type === 'network') {
|
|
75
|
+
if (data.type === 'network' && !isBeaconOrDenied) {
|
|
59
76
|
(0, _networkAdapter.reportNetworkRequest)({
|
|
60
77
|
method: data.method,
|
|
61
78
|
url: data.url,
|
|
@@ -78,8 +95,10 @@ class NetworkFeature {
|
|
|
78
95
|
// (correlated by sessionId+timestamp), so we no longer emit
|
|
79
96
|
// NETWORK_REQUEST_* replay events here (that produced duplicate rows
|
|
80
97
|
// and count divergence between replay_events and network_requests).
|
|
81
|
-
// Skip SDK's own upload calls to prevent recursive event spam
|
|
82
|
-
|
|
98
|
+
// Skip SDK's own upload calls to prevent recursive event spam, and anything the
|
|
99
|
+
// beacon/denyUrls filter excluded -- this is the lane that becomes the
|
|
100
|
+
// network_requests row a person sees on the session panel.
|
|
101
|
+
if (!isExcluded) {
|
|
83
102
|
const sessionManager = _SessionManager.SessionManager.getExistingInstance();
|
|
84
103
|
if (sessionManager) {
|
|
85
104
|
const backendTransport = sessionManager.getBackendTransport();
|
|
@@ -113,9 +132,24 @@ class NetworkFeature {
|
|
|
113
132
|
}
|
|
114
133
|
}
|
|
115
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Substring or RegExp, the same rule as the web SDK's `matchUrl`.
|
|
137
|
+
*
|
|
138
|
+
* Spelled out rather than shared because the two SDKs have no common runtime package, and the
|
|
139
|
+
* rule is two lines. What must NOT differ is the beacon list, which is why that one is a
|
|
140
|
+
* byte-identical vendored copy with a hash gate rather than a retyped constant.
|
|
141
|
+
*/
|
|
142
|
+
exports.NetworkFeature = NetworkFeature;
|
|
143
|
+
function matchesDenyUrl(url, patterns) {
|
|
144
|
+
if (!patterns || !patterns.length || !url) return false;
|
|
145
|
+
for (const p of patterns) {
|
|
146
|
+
if (typeof p === 'string' ? url.includes(p) : p.test(url)) return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
116
151
|
// Returns true if the URL is a ScaleBun SDK internal endpoint that must not be
|
|
117
152
|
// captured — doing so would cause recursive network event spam.
|
|
118
|
-
exports.NetworkFeature = NetworkFeature;
|
|
119
153
|
function isSdkInternalUrl(url, sdkBaseUrl) {
|
|
120
154
|
if (!url) return false;
|
|
121
155
|
try {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.THIRD_PARTY_BEACON_HOSTS = void 0;
|
|
7
|
+
exports.isThirdPartyBeacon = isThirdPartyBeacon;
|
|
8
|
+
/**
|
|
9
|
+
* THIRD-PARTY BEACON HOSTS — the one list, and the one matcher, for every layer that drops them.
|
|
10
|
+
*
|
|
11
|
+
* WHAT THIS IS FOR
|
|
12
|
+
* A session on an instrumented site spends most of its network tab on traffic the app did not
|
|
13
|
+
* meaningfully make: analytics collectors, ad pixels, IP-enrichment calls, tag managers. It is
|
|
14
|
+
* captured, uploaded, stored and rendered, and none of it answers a question anyone asks a session
|
|
15
|
+
* replay. One real sign-in screen was 27 requests, most of them beacons.
|
|
16
|
+
*
|
|
17
|
+
* It is not only clutter. Session health scores `failedRequests` and `slowRequests`, so a tracker
|
|
18
|
+
* killed by an ad blocker (a transport error) or one that is simply slow moves a number that is
|
|
19
|
+
* supposed to describe the APP. That is the part that makes this a correctness fix rather than a
|
|
20
|
+
* tidy-up.
|
|
21
|
+
*
|
|
22
|
+
* WHY HOSTS ONLY, NEVER PATHS
|
|
23
|
+
* The obvious rules from looking at a network tab are `/collect`, `/p`, `/api/v2/pixel`. Every one
|
|
24
|
+
* of them is a path that a customer's own API is allowed to have, and dropping a real API call is
|
|
25
|
+
* far worse than keeping a beacon: the noise is visible and annoying, the missing request is
|
|
26
|
+
* invisible and misleading. So matching is on the HOSTNAME, with a dot boundary, and a request to
|
|
27
|
+
* `/collect` on the app's own origin is kept — correctly.
|
|
28
|
+
*
|
|
29
|
+
* WHY IT IS A SUFFIX LIST AND NOT A REGEX
|
|
30
|
+
* Two reasons, both practical. It is applied inside an SDK whose eager bundle is measured in bytes,
|
|
31
|
+
* and a comma-joined string of suffixes compresses to almost nothing next to an equivalent pattern
|
|
32
|
+
* source. And it has to be COPIED, byte for byte, into the React Native SDK and into the backend's
|
|
33
|
+
* ingest path — a list with no syntax is a list that cannot drift in meaning between three
|
|
34
|
+
* languages' worth of tooling. `third-party:parity` hashes all three copies.
|
|
35
|
+
*
|
|
36
|
+
* ZERO IMPORTS, DELIBERATELY. That is what makes the byte-identical copy possible. If an edit here
|
|
37
|
+
* ever needs an import, the parity gate is the thing that will say so — keep the module pure rather
|
|
38
|
+
* than weakening the gate.
|
|
39
|
+
*
|
|
40
|
+
* WHAT IS DELIBERATELY NOT ON THE LIST
|
|
41
|
+
* The rule is: a host whose ONLY purpose is to receive telemetry. Everything else stays, because a
|
|
42
|
+
* dropped request is invisible and a kept one is merely noisy.
|
|
43
|
+
*
|
|
44
|
+
* - Feature-flag and experiment services (LaunchDarkly, Optimizely, Split). Their RESPONSES
|
|
45
|
+
* change what the app does, so a failed flag fetch is an application incident and the single
|
|
46
|
+
* most useful request in the session.
|
|
47
|
+
* - Chat and support widgets (Intercom, Zendesk, Drift). A broken widget is a broken feature the
|
|
48
|
+
* user can see.
|
|
49
|
+
* - `facebook.com` as a whole. `graph.facebook.com` is an API real apps call; only the pixel
|
|
50
|
+
* delivery hosts are listed.
|
|
51
|
+
* - CDNs of any kind, even ones that mostly serve tags: they also serve application assets, and
|
|
52
|
+
* dropping them would hide a real asset failure.
|
|
53
|
+
*
|
|
54
|
+
* When in doubt, leave it out. `denyUrls` stays the per-app escape hatch in both SDKs, and it can
|
|
55
|
+
* do what this list will not.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Known telemetry, advertising and enrichment endpoints, as hostname suffixes.
|
|
60
|
+
*
|
|
61
|
+
* One comma-joined string rather than an array: identical meaning, materially smaller once gzipped,
|
|
62
|
+
* and it keeps the copy in three repos a single line to compare by eye.
|
|
63
|
+
*/
|
|
64
|
+
const THIRD_PARTY_BEACON_HOSTS = exports.THIRD_PARTY_BEACON_HOSTS = 'google-analytics.com,googletagmanager.com,analytics.google.com,doubleclick.net,googlesyndication.com,' + 'googleadservices.com,app-measurement.com,facebook.net,analytics.tiktok.com,' + 'ads-twitter.com,analytics.twitter.com,ct.pinterest.com,bat.bing.com,px.ads.linkedin.com,tr.snapchat.com,' + 'criteo.com,criteo.net,taboola.com,outbrain.com,adroll.com,adsrvr.org,quantserve.com,scorecardresearch.com,' + 'hotjar.com,hotjar.io,clarity.ms,fullstory.com,mouseflow.com,crazyegg.com,luckyorange.com,inspectlet.com,' + 'segment.io,mixpanel.com,amplitude.com,heap.io,heapanalytics.com,rudderstack.com,posthog.com,' + 'matomo.cloud,plausible.io,simpleanalytics.com,statcounter.com,' + 'appsflyer.com,adjust.com,branch.io,' + 'ipify.org,ipinfo.io,ipapi.co,ip-api.com,geolocation-db.com,cloudflareinsights.com,' + 'nr-data.net,browser-intake-datadoghq.com,sentry.io,bugsnag.com,rollbar.com';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True when `url` is addressed to a known beacon host.
|
|
68
|
+
*
|
|
69
|
+
* Deliberately total: an unparseable or relative URL is NOT third party. A relative URL is
|
|
70
|
+
* same-origin by definition — the app's own call — and guessing on garbage input would drop real
|
|
71
|
+
* requests for no benefit.
|
|
72
|
+
*/
|
|
73
|
+
function isThirdPartyBeacon(url, hosts) {
|
|
74
|
+
if (!url) return false;
|
|
75
|
+
let host = '';
|
|
76
|
+
try {
|
|
77
|
+
// A relative URL throws here, which is the answer we want: it is first-party.
|
|
78
|
+
host = new URL(url).hostname.toLowerCase();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
if (!host) return false;
|
|
83
|
+
for (const suffix of (hosts ?? THIRD_PARTY_BEACON_HOSTS).split(',')) {
|
|
84
|
+
// Dot boundary, so `notevil-facebook.com` does not match `facebook.com` while
|
|
85
|
+
// `connect.facebook.com` does. The bare-equality arm covers the apex domain itself.
|
|
86
|
+
if (host === suffix || host.endsWith('.' + suffix)) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=thirdParty.js.map
|