@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 +3 -1
- package/dist/__tests__/google-webhook-open-warning.test.d.ts +15 -7
- package/dist/__tests__/google-webhook-open-warning.test.d.ts.map +1 -1
- package/dist/__tests__/google-webhook-unauthenticated.test.d.ts +30 -0
- package/dist/__tests__/google-webhook-unauthenticated.test.d.ts.map +1 -0
- package/dist/index.cjs +37 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +37 -10
- package/dist/index.js.map +1 -1
- package/dist/log-format.d.ts.map +1 -1
- package/dist/routes/webhook-google.d.ts +0 -17
- package/dist/routes/webhook-google.d.ts.map +1 -1
- package/package.json +2 -2
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
|
|
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
|
|
2
|
+
* Startup warnings for the Google RTDN route's two loose conditions.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 none — an
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
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
|
|
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)) {
|