@mpgd/adapter-ait 0.12.6 → 0.12.7

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.
@@ -8,6 +8,19 @@
8
8
  * response validation in its authority client. This helper only owns the
9
9
  * WebView transport invocation, so all game-owned authority endpoints can use
10
10
  * the same safe call shape.
11
+ *
12
+ * Authority calls carry grant evidence and idempotency headers, so the
13
+ * resource must resolve to an `https:` origin. Plain `http:` is accepted only
14
+ * for a loopback development authority (`localhost` or `127.0.0.1`).
15
+ *
16
+ * The transport receives exactly the representation that passed the policy
17
+ * check. A string, or a URL-like object (a string `href` and no `url`, which
18
+ * also covers a `URL` created in another realm such as an iframe), is
19
+ * forwarded as its validated absolute `href`. A same-realm `Request` is
20
+ * forwarded unchanged after its immutable `url` validated. Anything else is
21
+ * rejected: a Request-like object that is not a same-realm `Request` would be
22
+ * stringified by native fetch through `toString`, not `url`, so validating its
23
+ * `url` would not cover what is actually fetched.
11
24
  */
12
25
  export declare function fetchAitAuthority(input: {
13
26
  readonly resource: RequestInfo | URL;
@@ -8,11 +8,91 @@
8
8
  * response validation in its authority client. This helper only owns the
9
9
  * WebView transport invocation, so all game-owned authority endpoints can use
10
10
  * the same safe call shape.
11
+ *
12
+ * Authority calls carry grant evidence and idempotency headers, so the
13
+ * resource must resolve to an `https:` origin. Plain `http:` is accepted only
14
+ * for a loopback development authority (`localhost` or `127.0.0.1`).
15
+ *
16
+ * The transport receives exactly the representation that passed the policy
17
+ * check. A string, or a URL-like object (a string `href` and no `url`, which
18
+ * also covers a `URL` created in another realm such as an iframe), is
19
+ * forwarded as its validated absolute `href`. A same-realm `Request` is
20
+ * forwarded unchanged after its immutable `url` validated. Anything else is
21
+ * rejected: a Request-like object that is not a same-realm `Request` would be
22
+ * stringified by native fetch through `toString`, not `url`, so validating its
23
+ * `url` would not cover what is actually fetched.
11
24
  */
12
25
  export function fetchAitAuthority(input) {
13
26
  const fetchImplementation = input.fetch ?? globalThis.fetch;
14
27
  if (typeof fetchImplementation !== 'function') {
15
28
  return Promise.reject(new TypeError('AIT authority fetch is unavailable.'));
16
29
  }
17
- return fetchImplementation(input.resource, input.init);
30
+ const resolution = resolveAitAuthorityResource(input.resource);
31
+ if (!resolution.ok) {
32
+ return Promise.reject(resolution.error);
33
+ }
34
+ return fetchImplementation(resolution.resource, input.init);
35
+ }
36
+ const loopbackHostnames = new Set(['localhost', '127.0.0.1']);
37
+ function resolveAitAuthorityResource(resource) {
38
+ const candidate = readAitAuthorityResourceCandidate(resource);
39
+ if (candidate === undefined) {
40
+ return {
41
+ ok: false,
42
+ error: new TypeError('AIT authority resource must be a string, URL, or same-realm Request.'),
43
+ };
44
+ }
45
+ let url;
46
+ try {
47
+ url = new URL(candidate.href, globalThis.location?.href);
48
+ }
49
+ catch {
50
+ return {
51
+ ok: false,
52
+ error: new TypeError('AIT authority resource must be an absolute https: URL.'),
53
+ };
54
+ }
55
+ const policyError = checkAitAuthorityUrl(url);
56
+ if (policyError !== undefined) {
57
+ return { ok: false, error: policyError };
58
+ }
59
+ return {
60
+ ok: true,
61
+ resource: candidate.kind === 'request' ? candidate.request : url.href,
62
+ };
63
+ }
64
+ function checkAitAuthorityUrl(url) {
65
+ if (url.protocol === 'https:') {
66
+ return undefined;
67
+ }
68
+ if (url.protocol === 'http:' && loopbackHostnames.has(url.hostname)) {
69
+ return undefined;
70
+ }
71
+ return new TypeError(`AIT authority resource must use https: (http: is allowed only for localhost); received ${url.protocol}//${url.host}.`);
72
+ }
73
+ /**
74
+ * Reads the URL text once so the same value is both validated and forwarded.
75
+ *
76
+ * URL-like objects are recognized by shape (`instanceof URL` is false for a
77
+ * URL from another realm) and forwarded as their href string, so the object
78
+ * itself never reaches the transport. Requests are only accepted via a
79
+ * same-realm `instanceof Request`: the instance must be forwarded as-is, and
80
+ * native fetch stringifies any non-Request object through `toString` rather
81
+ * than reading `url`, so a duck-typed `{ url }` cannot be trusted.
82
+ */
83
+ function readAitAuthorityResourceCandidate(resource) {
84
+ if (typeof resource === 'string') {
85
+ return { kind: 'href', href: resource };
86
+ }
87
+ if (typeof resource !== 'object' || resource === null) {
88
+ return undefined;
89
+ }
90
+ if (typeof Request === 'function' && resource instanceof Request) {
91
+ return { kind: 'request', href: resource.url, request: resource };
92
+ }
93
+ const { href, url } = resource;
94
+ if (typeof href === 'string' && url === undefined) {
95
+ return { kind: 'href', href };
96
+ }
97
+ return undefined;
18
98
  }
package/dist/host.js CHANGED
@@ -16,6 +16,13 @@ const invalidBridgeRequestId = 'ait-invalid-request';
16
16
  const rewardedAdEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
17
17
  const iapEvidenceSchema = 'apps-in-toss.iap.callback.v1';
18
18
  const minimumPromotionTossAppVersion = '5.232.0';
19
+ /**
20
+ * Every adapter-owned durable marker (purchase attempts, promotion grants,
21
+ * recovery cursors) lives under this prefix. Game code shares the same native
22
+ * storage, so `storage.save`/`storage.load` must never touch these keys: a
23
+ * forged `completed` marker would otherwise short-circuit purchase verification.
24
+ */
25
+ const reservedStorageKeyPrefix = 'mpgd:ait:';
19
26
  const promotionGrantStoragePrefix = 'mpgd:ait:promotion-grant:v1:';
20
27
  /**
21
28
  * A client idempotency key must survive a bridge reload. The authoritative
@@ -372,6 +379,7 @@ export function createAitHostBridge(options = {}) {
372
379
  }
373
380
  case 'ads.showRewarded': {
374
381
  const placementId = readPlacementId(request.payload);
382
+ const correlationId = readRewardedAdCorrelationId(request.payload, request.id);
375
383
  const adGroupId = adGroupIds.get(placementId);
376
384
  if (adGroupId === undefined
377
385
  || adPlacementTypes.get(placementId) !== 'rewarded'
@@ -379,13 +387,15 @@ export function createAitHostBridge(options = {}) {
379
387
  return ok(request, { status: 'unavailable', rewardGranted: false });
380
388
  }
381
389
  const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'rewarded', async () => {
382
- const correlationId = readIdempotencyKey(request.payload, request.id);
383
390
  const result = await showRewardedAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
384
391
  return result.rewardGranted
385
392
  ? {
386
393
  ...result,
387
- // game-services forwards this as platformImpressionId and compares it
388
- // with the native callback correlationId during authority verification.
394
+ // The correlationId is client-supplied evidence, not a native receipt:
395
+ // the Apps in Toss show call only carries the adGroupId, so nothing
396
+ // native echoes it back. game-services forwards it as
397
+ // platformImpressionId; the backend rewardAuthority must verify it
398
+ // (and the rest of this evidence) before granting any reward.
389
399
  ledgerEntryId: correlationId,
390
400
  evidence: {
391
401
  schema: rewardedAdEvidenceSchema,
@@ -1030,11 +1040,19 @@ function showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maxim
1030
1040
  }
1031
1041
  });
1032
1042
  }
1043
+ /**
1044
+ * Launch and inbound-share parameters arrive from untrusted deep links. Bound
1045
+ * each forwarded value and the nested `queryParams` JSON so a hostile link
1046
+ * cannot push oversized or unbounded data into the game.
1047
+ */
1048
+ const maximumLaunchParamLength = 256;
1049
+ const maximumNestedQueryParamsJsonLength = 4_096;
1050
+ const maximumNestedQueryParamCount = 32;
1033
1051
  function getLaunchIntent() {
1034
1052
  const params = inboundSearchParams();
1035
- const challengeToken = nonEmptyParam(params.get('challengeToken'));
1036
- const puzzleId = nonEmptyParam(params.get('puzzleId'));
1037
- const requestedEntry = nonEmptyParam(params.get('entry'));
1053
+ const challengeToken = boundedParam(params.get('challengeToken'));
1054
+ const puzzleId = boundedParam(params.get('puzzleId'));
1055
+ const requestedEntry = boundedParam(params.get('entry'));
1038
1056
  let entry;
1039
1057
  if (requestedEntry !== undefined && launchEntries.has(requestedEntry)) {
1040
1058
  entry = requestedEntry;
@@ -1053,8 +1071,8 @@ function getLaunchIntent() {
1053
1071
  }
1054
1072
  function readInboundShare() {
1055
1073
  const params = inboundSearchParams();
1056
- const puzzleId = nonEmptyParam(params.get('puzzleId'));
1057
- const challengeToken = nonEmptyParam(params.get('challengeToken'));
1074
+ const puzzleId = boundedParam(params.get('puzzleId'));
1075
+ const challengeToken = boundedParam(params.get('challengeToken'));
1058
1076
  return puzzleId === undefined && challengeToken === undefined
1059
1077
  ? null
1060
1078
  : {
@@ -1065,12 +1083,24 @@ function readInboundShare() {
1065
1083
  function inboundSearchParams() {
1066
1084
  const params = new URLSearchParams(globalThis.location?.search ?? '');
1067
1085
  const nested = params.get('queryParams');
1068
- if (nested !== null) {
1086
+ if (nested !== null && nested.length <= maximumNestedQueryParamsJsonLength) {
1069
1087
  try {
1070
1088
  const parsed = JSON.parse(nested);
1071
1089
  if (isRecord(parsed)) {
1090
+ let merged = 0;
1072
1091
  for (const [key, value] of Object.entries(parsed)) {
1073
- if (typeof value === 'string' && !params.has(key)) {
1092
+ if (merged >= maximumNestedQueryParamCount) {
1093
+ break;
1094
+ }
1095
+ // Direct search params win; nested values only fill missing keys and
1096
+ // must be bounded strings. Keys are counted whether or not they merge
1097
+ // so an attacker cannot pad past the cap with ignored entries.
1098
+ merged += 1;
1099
+ if (typeof value === 'string'
1100
+ && key.length > 0
1101
+ && key.length <= maximumLaunchParamLength
1102
+ && value.length <= maximumLaunchParamLength
1103
+ && !params.has(key)) {
1074
1104
  params.set(key, value);
1075
1105
  }
1076
1106
  }
@@ -2170,6 +2200,9 @@ function readStorageKey(payload) {
2170
2200
  if (typeof key !== 'string' || key.length === 0) {
2171
2201
  throw new TypeError('AIT storage key must be a non-empty string.');
2172
2202
  }
2203
+ if (key.startsWith(reservedStorageKeyPrefix)) {
2204
+ throw new TypeError(`AIT storage keys starting with "${reservedStorageKeyPrefix}" are reserved for adapter-owned markers.`);
2205
+ }
2173
2206
  return key;
2174
2207
  }
2175
2208
  function readPlacementId(payload) {
@@ -2186,9 +2219,24 @@ function readBannerSurfaceId(payload) {
2186
2219
  }
2187
2220
  return surfaceId;
2188
2221
  }
2189
- function readIdempotencyKey(payload, fallback) {
2222
+ /**
2223
+ * A rewarded-ad idempotencyKey becomes the client-supplied evidence
2224
+ * correlationId. It is optional, but when present it must obey the same
2225
+ * bounded visible-character rule as purchase keys so a game cannot push
2226
+ * arbitrary blobs into reward evidence.
2227
+ */
2228
+ function readRewardedAdCorrelationId(payload, fallback) {
2190
2229
  const value = readPayloadRecord(payload).idempotencyKey;
2191
- return typeof value === 'string' && value.length > 0 ? value : fallback;
2230
+ if (value === undefined) {
2231
+ return fallback;
2232
+ }
2233
+ if (typeof value !== 'string'
2234
+ || value.length === 0
2235
+ || value.length > 256
2236
+ || /[\p{Cc}\p{Cf}]/u.test(value)) {
2237
+ throw new TypeError('AIT rewarded ad idempotencyKey must contain 1 to 256 visible characters.');
2238
+ }
2239
+ return value;
2192
2240
  }
2193
2241
  function readCommercePurchase(payload) {
2194
2242
  const value = readPayloadRecord(payload);
@@ -2256,8 +2304,10 @@ function ok(request, data) {
2256
2304
  function createBridgeError(id, code, message, retryable = false) {
2257
2305
  return { id, ok: false, error: { code, message, retryable } };
2258
2306
  }
2259
- function nonEmptyParam(value) {
2260
- return value === null || value.length === 0 ? undefined : value;
2307
+ function boundedParam(value) {
2308
+ return value === null || value.length === 0 || value.length > maximumLaunchParamLength
2309
+ ? undefined
2310
+ : value;
2261
2311
  }
2262
2312
  function isAbortError(error) {
2263
2313
  return isRecord(error) && error.name === 'AbortError';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-ait",
3
- "version": "0.12.6",
3
+ "version": "0.12.7",
4
4
  "description": "Apps in Toss WebView platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {