mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
Fix webhook signature verification (#344)
Two commits. **The webhook fix.** Every delivery was being refused as a signature mismatch. The provider signs one of two ways and the SDK helper only implements the older one, so a `whsec_`-prefixed secret — which is what any secret created now is — was mangled before verification. The scheme is now read off the secret itself, so rotating one cannot put the two out of step. Verified against a real signature, not only against a rejection. **A leak fix that was never pushed.** `apps/nesdoctor/src/sys.rs` and `ask.rs` still cite an internal document by filename in comments that reach a public repo. The commit fixing it existed only on a local branch. Rebased on and included here; the third file it touched had already been fixed differently. 344 tests pass.
This commit is contained in:
@@ -28,11 +28,12 @@ pub struct Answers {
|
|||||||
/// currently reach are already doing for themselves. That is the
|
/// currently reach are already doing for themselves. That is the
|
||||||
/// uncomfortable possibility, which is the reason to ask rather than not.
|
/// uncomfortable possibility, which is the reason to ask rather than not.
|
||||||
pub want: Option<String>,
|
pub want: Option<String>,
|
||||||
/// USERS.md 7, roughly: is this machine a host, a client, or both?
|
/// Roughly: is this machine a host, a client, or both?
|
||||||
pub role: Option<String>,
|
pub role: Option<String>,
|
||||||
/// USERS.md 6: cash or credit. Only asked of a machine that could host.
|
/// Cash or credit. Only asked of a machine that could host.
|
||||||
pub share_for: Option<String>,
|
pub share_for: Option<String>,
|
||||||
/// USERS.md 5, in its factual form: current spend, not willingness to pay.
|
/// The factual form of what they pay now: current spend, not willingness
|
||||||
|
/// to pay.
|
||||||
pub pays_today: Option<String>,
|
pub pays_today: Option<String>,
|
||||||
/// Asked only of a non-Linux machine: is there a Linux box behind it?
|
/// Asked only of a non-Linux machine: is there a Linux box behind it?
|
||||||
pub other_linux: Option<String>,
|
pub other_linux: Option<String>,
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ pub struct Gpu {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub vendor: Option<String>,
|
pub vendor: Option<String>,
|
||||||
/// The DRM render node, where one exists. Linux only, and a hard
|
/// The DRM render node, where one exists. Linux only, and a hard
|
||||||
/// requirement in `contracts/host-requirements.md`: a card without one
|
/// requirement: a card without one cannot host, however good it is.
|
||||||
/// cannot host, however good it is.
|
/// ref(d-0002)
|
||||||
pub render_node: Option<String>,
|
pub render_node: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
bun.lock
2
bun.lock
@@ -81,6 +81,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"postgresql": "^0.0.1",
|
"postgresql": "^0.0.1",
|
||||||
|
"standardwebhooks": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zod-openapi": "^6.0.0",
|
"zod-openapi": "^6.0.0",
|
||||||
},
|
},
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
"hono": "^4.12.31",
|
"hono": "^4.12.31",
|
||||||
|
"standardwebhooks": "^1.1.1",
|
||||||
"typescript": "^7.0.1-rc",
|
"typescript": "^7.0.1-rc",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"hono": "^4.12.31",
|
"hono": "^4.12.31",
|
||||||
"typescript": "^7.0.1-rc",
|
"typescript": "^7.0.1-rc",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"@polar-sh/sdk": "^0.49.0"
|
"@polar-sh/sdk": "^0.49.0",
|
||||||
|
"standardwebhooks": "^1.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"postgresql": "^0.0.1",
|
"postgresql": "^0.0.1",
|
||||||
|
"standardwebhooks": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zod-openapi": "^6.0.0"
|
"zod-openapi": "^6.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
import { Polar } from '@polar-sh/sdk';
|
import { Polar } from '@polar-sh/sdk';
|
||||||
import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presentmentcurrency.js';
|
import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presentmentcurrency.js';
|
||||||
|
|
||||||
|
type Price = { currency: PresentmentCurrency; amount: number };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The paid rung of the self-serve ladder.
|
* The paid rung of the self-serve ladder.
|
||||||
*
|
*
|
||||||
@@ -32,16 +34,29 @@ import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presen
|
|||||||
*
|
*
|
||||||
* Amounts are in minor units: 2000 is 20.00.
|
* Amounts are in minor units: 2000 is 20.00.
|
||||||
*/
|
*/
|
||||||
const PRODUCT = {
|
const PRODUCTS = [
|
||||||
name: 'Nestri Pro',
|
{
|
||||||
description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.',
|
env: 'POLAR_FREE_PRODUCT_ID',
|
||||||
recurringInterval: 'month' as const,
|
name: 'Nestri Free',
|
||||||
prices: [
|
description: 'Sessions on hardware you own, and a monthly burn allowance.',
|
||||||
{ currency: 'usd', amount: 2000 },
|
// Recurring, and priced at nothing. It has to be a *subscription* rather
|
||||||
{ currency: 'eur', amount: 2000 },
|
// than a one-time purchase, because a team is put on it outright at
|
||||||
{ currency: 'gbp', amount: 2000 }
|
// signup and a one-time product has no subscription to create.
|
||||||
] satisfies { currency: PresentmentCurrency; amount: number }[]
|
recurringInterval: 'month' as const,
|
||||||
};
|
prices: [{ currency: 'usd', amount: 0 }] satisfies Price[]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
env: 'POLAR_PRODUCT_ID',
|
||||||
|
name: 'Nestri Pro',
|
||||||
|
description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.',
|
||||||
|
recurringInterval: 'month' as const,
|
||||||
|
prices: [
|
||||||
|
{ currency: 'usd', amount: 2000 },
|
||||||
|
{ currency: 'eur', amount: 2000 },
|
||||||
|
{ currency: 'gbp', amount: 2000 }
|
||||||
|
] satisfies Price[]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const apply = process.argv.includes('--apply');
|
const apply = process.argv.includes('--apply');
|
||||||
const accessToken = process.env.POLAR_ACCESS_TOKEN;
|
const accessToken = process.env.POLAR_ACCESS_TOKEN;
|
||||||
@@ -80,34 +95,49 @@ console.log(`server: ${server}`);
|
|||||||
console.log(`organization: ${organization.name} (${organization.id})`);
|
console.log(`organization: ${organization.name} (${organization.id})`);
|
||||||
|
|
||||||
const existing = await polar.products.list({ organizationId: organization.id, limit: 100 });
|
const existing = await polar.products.list({ organizationId: organization.id, limit: 100 });
|
||||||
const clash = existing.result.items.find((p) => p.name === PRODUCT.name && !p.isArchived);
|
|
||||||
if (clash) {
|
|
||||||
console.log(`\nalready there: ${PRODUCT.name} (${clash.id})`);
|
|
||||||
console.log('nothing to do. Archive it first if you meant to replace it.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nwould create: ${PRODUCT.name}, every ${PRODUCT.recurringInterval}`);
|
for (const product of PRODUCTS) {
|
||||||
for (const price of PRODUCT.prices) {
|
const clash = existing.result.items.find((p) => p.name === product.name && !p.isArchived);
|
||||||
console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`);
|
if (clash) {
|
||||||
|
// Same name is not the same product. A one-time product where a
|
||||||
|
// subscription is wanted cannot be subscribed to at all, and reporting
|
||||||
|
// it as already-there would hand back an id that fails at the first use.
|
||||||
|
if (clash.recurringInterval !== product.recurringInterval) {
|
||||||
|
console.log(`\nwrong shape: ${product.name} (${clash.id})`);
|
||||||
|
console.log(
|
||||||
|
` wanted every ${product.recurringInterval}, found ${clash.recurringInterval ?? 'one-time'}`
|
||||||
|
);
|
||||||
|
console.log(' archive it first, then run this again.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`\nalready there: ${product.name}`);
|
||||||
|
console.log(` ${product.env}=${clash.id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nwould create: ${product.name}, every ${product.recurringInterval}`);
|
||||||
|
for (const price of product.prices) {
|
||||||
|
console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apply) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await polar.products.create({
|
||||||
|
...(scopedToOrganization ? {} : { organizationId: organization.id }),
|
||||||
|
name: product.name,
|
||||||
|
description: product.description,
|
||||||
|
recurringInterval: product.recurringInterval,
|
||||||
|
prices: product.prices.map((price) => ({
|
||||||
|
amountType: 'fixed' as const,
|
||||||
|
priceCurrency: price.currency,
|
||||||
|
priceAmount: price.amount
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
console.log(` created: ${product.env}=${created.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!apply) {
|
if (!apply) {
|
||||||
console.log('\nnothing written. Re-run with --apply to create it.');
|
console.log('\nnothing written. Re-run with --apply to create them.');
|
||||||
process.exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const created = await polar.products.create({
|
|
||||||
...(scopedToOrganization ? {} : { organizationId: organization.id }),
|
|
||||||
name: PRODUCT.name,
|
|
||||||
description: PRODUCT.description,
|
|
||||||
recurringInterval: PRODUCT.recurringInterval,
|
|
||||||
prices: PRODUCT.prices.map((price) => ({
|
|
||||||
amountType: 'fixed' as const,
|
|
||||||
priceCurrency: price.currency,
|
|
||||||
priceAmount: price.amount
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\ncreated: ${created.id}`);
|
|
||||||
console.log(`set POLAR_PRODUCT_ID=${created.id} for the ${server} deployment.`);
|
|
||||||
|
|||||||
@@ -137,3 +137,53 @@ describe('Webhooks', () => {
|
|||||||
expect(() => Polar.receive({ body: '{}', headers: {} })).toThrow(/not configured/);
|
expect(() => Polar.receive({ body: '{}', headers: {} })).toThrow(/not configured/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Both signing schemes', () => {
|
||||||
|
// A `whsec_` secret is Standard Webhooks, where the secret is a base64 key.
|
||||||
|
// The SDK helper only implements the older scheme and base64-encodes
|
||||||
|
// whatever it is handed, so a Standard Webhooks secret verified through it
|
||||||
|
// fails every time, for a reason no error message mentions. These assert the
|
||||||
|
// prefix is what picks, so rotating a secret cannot put the two out of step.
|
||||||
|
test('a Standard Webhooks secret is verified, and a real signature passes', async () => {
|
||||||
|
const { Webhook } = await import('standardwebhooks');
|
||||||
|
const key = Buffer.from('a'.repeat(32)).toString('base64');
|
||||||
|
const secret = `whsec_${key}`;
|
||||||
|
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
type: 'subscription.active',
|
||||||
|
data: { customer: { externalId: 'tem_x' }, productId: PAID }
|
||||||
|
});
|
||||||
|
const id = 'msg_1';
|
||||||
|
const timestamp = new Date();
|
||||||
|
const signature = new Webhook(secret).sign(id, timestamp, body);
|
||||||
|
|
||||||
|
const delivery = Polar.receive({
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'webhook-id': id,
|
||||||
|
'webhook-timestamp': Math.floor(timestamp.getTime() / 1000).toString(),
|
||||||
|
'webhook-signature': signature
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(delivery.type).toBe('subscription.active');
|
||||||
|
expect(delivery.teamId).toBe('tem_x');
|
||||||
|
expect(delivery.standing).toEqual({ plan: 'paid', status: 'active' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a tampered body under a valid-looking signature is refused', () => {
|
||||||
|
const secret = `whsec_${Buffer.from('a'.repeat(32)).toString('base64')}`;
|
||||||
|
configure({ POLAR_WEBHOOK_SECRET: secret });
|
||||||
|
expect(() =>
|
||||||
|
Polar.receive({
|
||||||
|
body: '{"type":"subscription.revoked"}',
|
||||||
|
headers: {
|
||||||
|
'webhook-id': 'msg_1',
|
||||||
|
'webhook-timestamp': '1',
|
||||||
|
'webhook-signature': 'v1,AAAA'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).toThrow(/Signature/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Polar as PolarSdk } from '@polar-sh/sdk';
|
import { Polar as PolarSdk } from '@polar-sh/sdk';
|
||||||
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
|
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
|
||||||
|
import { Webhook as StandardWebhook } from 'standardwebhooks';
|
||||||
import z from 'zod';
|
import z from 'zod';
|
||||||
|
|
||||||
import { Env } from '../env.js';
|
import { Env } from '../env.js';
|
||||||
@@ -257,11 +258,31 @@ export namespace Polar {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let event;
|
// Two signing schemes, told apart by the secret itself.
|
||||||
|
//
|
||||||
|
// A `whsec_` prefix means Standard Webhooks, where the secret is a
|
||||||
|
// base64 key the library decodes. Anything else is the older scheme,
|
||||||
|
// where the secret is used as its own raw bytes. The SDK's helper
|
||||||
|
// only implements the older one — it base64-encodes whatever it is
|
||||||
|
// handed, which turns a Standard Webhooks secret into the literal
|
||||||
|
// bytes of the string including the prefix, and then every signature
|
||||||
|
// fails to match for a reason no error message mentions.
|
||||||
|
//
|
||||||
|
// Reading the prefix rather than configuring which scheme is in use
|
||||||
|
// means rotating a secret cannot put the two out of step.
|
||||||
|
let event: { type: string; data?: Record<string, unknown> };
|
||||||
try {
|
try {
|
||||||
event = validateEvent(input.body, input.headers, webhookSecret);
|
if (webhookSecret.startsWith('whsec_')) {
|
||||||
|
const verified = new StandardWebhook(webhookSecret).verify(input.body, input.headers);
|
||||||
|
event = verified as { type: string; data?: Record<string, unknown> };
|
||||||
|
} else {
|
||||||
|
event = validateEvent(input.body, input.headers, webhookSecret) as {
|
||||||
|
type: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof WebhookVerificationError) {
|
if (error instanceof WebhookVerificationError || error instanceof Error) {
|
||||||
throw new VisibleError(
|
throw new VisibleError(
|
||||||
'authentication',
|
'authentication',
|
||||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||||
@@ -271,7 +292,7 @@ export namespace Polar {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (event as { data?: Record<string, unknown> }).data ?? {};
|
const data = event.data ?? {};
|
||||||
const customer = data.customer as { externalId?: string | null } | undefined;
|
const customer = data.customer as { externalId?: string | null } | undefined;
|
||||||
// `externalId` is the team id we put on the customer. A delivery
|
// `externalId` is the team id we put on the customer. A delivery
|
||||||
// without one is about a customer created some other way — by hand in
|
// without one is about a customer created some other way — by hand in
|
||||||
|
|||||||
Reference in New Issue
Block a user