@onesub/server 0.26.0 → 0.27.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/README.md CHANGED
@@ -323,7 +323,9 @@ Canonical Postgres DDL shipped at [`sql/schema.sql`](./sql/schema.sql). Apply wi
323
323
  ## Security
324
324
 
325
325
  - Apple JWS signature verified end-to-end against **Apple Root CA G3** (as of `0.6.0`)
326
- - Google RTDN: `Authorization: Bearer` JWT verified against Google JWKS when `pushAudience` is configured
326
+ - Google RTDN: `Authorization: Bearer` JWT verified against Google JWKS when `pushAudience` is
327
+ configured. Without it the request cannot be attributed to Google and is refused with 401 under
328
+ `NODE_ENV=production`, unless `google.allowUnauthenticatedWebhook` opts in
327
329
  - `transactionId` ownership enforced — same receipt can't be reused across users (`0.5.0+`)
328
330
  - zod input validation + 50 KB body cap
329
331
  - Full write-up: [`docs/SECURITY.md`](../../docs/SECURITY.md)
@@ -1,12 +1,20 @@
1
1
  /**
2
- * Startup warning for the two conditions that leave the Google RTDN route open.
2
+ * Startup warnings for the Google RTDN route's two loose conditions.
3
3
  *
4
- * `POST /onesub/webhook/google` verifies the Pub/Sub OIDC token only when an app
5
- * declares `pushAudience`, and serves any `packageName` when none declares one.
6
- * The route is mounted either way. Both behaviours are pre-existing and
7
- * deliberate a host may front the route with its own verificationso this
8
- * warns instead of refusing, and these tests pin down that it says so exactly
9
- * when it should and stays quiet otherwise.
4
+ * The authentication half changed in 0.27.0: without a `pushAudience` the route used
5
+ * to accept the request, and now refuses it with 401 in production unless
6
+ * `google.allowUnauthenticatedWebhook` says otherwise. So there are two warnings to
7
+ * tell apart, and getting them backwards would be worse than having nonean
8
+ * operator reading "insecure" when the endpoint is actually refusing traffic goes
9
+ * hunting for a breach instead of a missing config value.
10
+ *
11
+ * Open mode (no `packageName`) is unchanged: it still warns rather than refusing.
12
+ *
13
+ * The assertions here were rewritten, not adjusted. Three of them were negative
14
+ * matches against the old warning's wording — text this file no longer produces at
15
+ * all, so they would have passed however loudly the new warning fired. A negative
16
+ * assertion against a string that cannot occur is not coverage, so each one now
17
+ * names both current warnings explicitly.
10
18
  */
11
19
  export {};
12
20
  //# sourceMappingURL=google-webhook-open-warning.test.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"google-webhook-open-warning.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/google-webhook-open-warning.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG"}
1
+ {"version":3,"file":"google-webhook-open-warning.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/google-webhook-open-warning.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG"}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `POST /onesub/webhook/google` refuses requests it cannot attribute to Google.
3
+ *
4
+ * ## Why this replaced masking the purchase token
5
+ *
6
+ * The RTDN paths are only dangerous because a caller who knows a `purchaseToken` or
7
+ * an `orderId` could reach them unauthenticated: the voided-purchase branch acts on
8
+ * the payload alone, cancelling a subscription by token or deleting a one-time
9
+ * purchase row by order id.
10
+ *
11
+ * Treating those ids as secrets does not work. For a Google subscription the purchase
12
+ * token **is** the record's `originalTransactionId` (`providers/google.ts` keys on it
13
+ * deliberately — RTDNs and `linkedPurchaseToken` chains carry nothing else), so it
14
+ * lives in the database, in every notification payload, and in the webhook lines
15
+ * where it is the subject of the investigation. Redacting it from logs would have left
16
+ * the capability intact while suggesting otherwise.
17
+ *
18
+ * Refusing unattributable requests removes the capability instead.
19
+ *
20
+ * ## Production-gated, and why that is not a loophole
21
+ *
22
+ * Locally and in CI no real RTDN arrives, and requiring Pub/Sub credentials there
23
+ * would break every webhook test in this repo and the `onesub dev` server for no
24
+ * security gain. This matches how the mockMode hard guard and the sandbox-receipt
25
+ * rejection are already gated. The residual risk — a production deployment that
26
+ * forgets `NODE_ENV=production` — is the same one those two already carry, so this
27
+ * adds no new footgun, and the boot warning fires on the same condition.
28
+ */
29
+ export {};
30
+ //# sourceMappingURL=google-webhook-unauthenticated.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google-webhook-unauthenticated.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/google-webhook-unauthenticated.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG"}
package/dist/index.cjs CHANGED
@@ -174,6 +174,8 @@ var APPLE_ROOT_CA_PEMS = [APPLE_ROOT_CA_G3_PEM];
174
174
  var LOG_CONTINUATION = "\n | ";
175
175
  var BARE_VALUE = /^[\w.:/@+-]+$/;
176
176
  var LINE_TERMINATORS = /\r\n|\r|\n|\u2028|\u2029/;
177
+ var MAX_VALUE_CHARS = 512;
178
+ var PLAY_TOKEN_IN_URL = /\/tokens\/[^/:"'\s\\]+/g;
177
179
  var MAX_CAUSE_DEPTH = 2;
178
180
  var MAX_AGGREGATE_ERRORS = 3;
179
181
  function esc(value) {
@@ -182,6 +184,13 @@ function esc(value) {
182
184
  function escQuoted(value) {
183
185
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
184
186
  }
187
+ function clamp(value) {
188
+ if (value.length <= MAX_VALUE_CHARS) return value;
189
+ return `${value.slice(0, MAX_VALUE_CHARS)}\u2026+${value.length - MAX_VALUE_CHARS} more`;
190
+ }
191
+ function sanitize(value) {
192
+ return clamp(value.replace(PLAY_TOKEN_IN_URL, "/tokens/[redacted]"));
193
+ }
185
194
  function renderValue(value) {
186
195
  if (value === void 0) return void 0;
187
196
  if (value === null) return "null";
@@ -191,17 +200,19 @@ function renderValue(value) {
191
200
  return String(value);
192
201
  case "bigint":
193
202
  return `${value}n`;
194
- case "string":
195
- return BARE_VALUE.test(value) ? value : `"${escQuoted(value)}"`;
203
+ case "string": {
204
+ const safe = sanitize(value);
205
+ return BARE_VALUE.test(safe) ? safe : `"${escQuoted(safe)}"`;
206
+ }
196
207
  case "object":
197
208
  try {
198
- return `"${escQuoted(JSON.stringify(value) ?? "null")}"`;
209
+ return `"${escQuoted(sanitize(JSON.stringify(value) ?? "null"))}"`;
199
210
  } catch {
200
211
  return '"[unserialisable]"';
201
212
  }
202
213
  default:
203
214
  try {
204
- return `"${escQuoted(String(value))}"`;
215
+ return `"${escQuoted(sanitize(String(value)))}"`;
205
216
  } catch {
206
217
  return '"[unrenderable]"';
207
218
  }
@@ -1958,15 +1969,26 @@ var GOOGLE_FAILURE_MESSAGES = {
1958
1969
  function servesGoogle(config) {
1959
1970
  return getAppRegistry(config).apps.some((app) => !!app.google);
1960
1971
  }
1972
+ function googleWebhookIsAuthenticated(config) {
1973
+ return getAppRegistry(config).apps.some((a) => a.google?.pushAudience);
1974
+ }
1975
+ function googleWebhookAllowsUnauthenticated(config) {
1976
+ return getAppRegistry(config).apps.some((a) => a.google?.allowUnauthenticatedWebhook);
1977
+ }
1961
1978
  function warnIfGoogleWebhookOpen(config) {
1962
1979
  if (process.env["NODE_ENV"] !== "production") return;
1963
1980
  if (!servesGoogle(config)) return;
1964
1981
  const apps = getAppRegistry(config).apps;
1965
- const googleApps = apps.map((a) => a.google).filter((g) => !!g);
1966
- if (!googleApps.some((g) => g.pushAudience)) {
1967
- log.warn(
1968
- "[onesub] SECURITY: POST /onesub/webhook/google accepts unauthenticated requests \u2014 no configured app sets google.pushAudience, so the Pub/Sub OIDC token is never verified. A caller who knows a purchaseToken or orderId can cancel a subscription or delete a one-time purchase. Set google.pushAudience (and google.pushServiceAccountEmail)."
1969
- );
1982
+ if (!googleWebhookIsAuthenticated(config)) {
1983
+ if (googleWebhookAllowsUnauthenticated(config)) {
1984
+ log.warn(
1985
+ "[onesub] SECURITY: POST /onesub/webhook/google runs unauthenticated by explicit opt-in \u2014 google.allowUnauthenticatedWebhook is set and no app sets google.pushAudience. Anything that can reach this endpoint can cancel a subscription or delete a one-time purchase, so the request must already be authenticated in front of this server."
1986
+ );
1987
+ } else {
1988
+ log.warn(
1989
+ "[onesub] POST /onesub/webhook/google will reject every request with 401 \u2014 no configured app sets google.pushAudience, so the Pub/Sub OIDC token cannot be verified. Set google.pushAudience (and google.pushServiceAccountEmail), or set google.allowUnauthenticatedWebhook when something in front of this server already authenticates the request."
1990
+ );
1991
+ }
1970
1992
  }
1971
1993
  if (!apps.some((a) => a.google?.packageName)) {
1972
1994
  log.warn(
@@ -2095,7 +2117,12 @@ async function processGoogleNotification(work, config, store, purchaseStore) {
2095
2117
  }
2096
2118
  async function handleGoogleWebhook(req, res, config, store, purchaseStore, webhookEventStore, webhookQueue) {
2097
2119
  const pushIdentities = getAppRegistry(config).apps.map((app) => app.google).filter((g) => !!g?.pushAudience);
2098
- if (pushIdentities.length > 0) {
2120
+ if (pushIdentities.length === 0) {
2121
+ if (process.env["NODE_ENV"] === "production" && !googleWebhookAllowsUnauthenticated(config)) {
2122
+ sendError(res, 401, shared.ONESUB_ERROR_CODE.UNAUTHORIZED, "Unauthorized");
2123
+ return;
2124
+ }
2125
+ } else {
2099
2126
  let authenticated = false;
2100
2127
  for (const google of pushIdentities) {
2101
2128
  if (await verifyGooglePushToken(req, google.pushAudience, google.pushServiceAccountEmail)) {