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
|
||||
/// uncomfortable possibility, which is the reason to ask rather than not.
|
||||
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>,
|
||||
/// 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>,
|
||||
/// 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>,
|
||||
/// Asked only of a non-Linux machine: is there a Linux box behind it?
|
||||
pub other_linux: Option<String>,
|
||||
|
||||
@@ -48,8 +48,8 @@ pub struct Gpu {
|
||||
pub name: String,
|
||||
pub vendor: Option<String>,
|
||||
/// The DRM render node, where one exists. Linux only, and a hard
|
||||
/// requirement in `contracts/host-requirements.md`: a card without one
|
||||
/// cannot host, however good it is.
|
||||
/// requirement: a card without one cannot host, however good it is.
|
||||
/// ref(d-0002)
|
||||
pub render_node: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
2
bun.lock
2
bun.lock
@@ -81,6 +81,7 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.9",
|
||||
"postgresql": "^0.0.1",
|
||||
"standardwebhooks": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "^6.0.0",
|
||||
},
|
||||
@@ -101,6 +102,7 @@
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "^26.1.1",
|
||||
"hono": "^4.12.31",
|
||||
"standardwebhooks": "^1.1.1",
|
||||
"typescript": "^7.0.1-rc",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"hono": "^4.12.31",
|
||||
"typescript": "^7.0.1-rc",
|
||||
"zod": "^4.4.3",
|
||||
"@polar-sh/sdk": "^0.49.0"
|
||||
"@polar-sh/sdk": "^0.49.0",
|
||||
"standardwebhooks": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.9",
|
||||
"postgresql": "^0.0.1",
|
||||
"standardwebhooks": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"zod-openapi": "^6.0.0"
|
||||
},
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
import { Polar } from '@polar-sh/sdk';
|
||||
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.
|
||||
*
|
||||
@@ -32,7 +34,19 @@ import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presen
|
||||
*
|
||||
* Amounts are in minor units: 2000 is 20.00.
|
||||
*/
|
||||
const PRODUCT = {
|
||||
const PRODUCTS = [
|
||||
{
|
||||
env: 'POLAR_FREE_PRODUCT_ID',
|
||||
name: 'Nestri Free',
|
||||
description: 'Sessions on hardware you own, and a monthly burn allowance.',
|
||||
// Recurring, and priced at nothing. It has to be a *subscription* rather
|
||||
// than a one-time purchase, because a team is put on it outright at
|
||||
// signup and a one-time product has no subscription to create.
|
||||
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,
|
||||
@@ -40,8 +54,9 @@ const PRODUCT = {
|
||||
{ currency: 'usd', amount: 2000 },
|
||||
{ currency: 'eur', amount: 2000 },
|
||||
{ currency: 'gbp', amount: 2000 }
|
||||
] satisfies { currency: PresentmentCurrency; amount: number }[]
|
||||
};
|
||||
] satisfies Price[]
|
||||
}
|
||||
];
|
||||
|
||||
const apply = process.argv.includes('--apply');
|
||||
const accessToken = process.env.POLAR_ACCESS_TOKEN;
|
||||
@@ -80,34 +95,49 @@ console.log(`server: ${server}`);
|
||||
console.log(`organization: ${organization.name} (${organization.id})`);
|
||||
|
||||
const existing = await polar.products.list({ organizationId: organization.id, limit: 100 });
|
||||
const clash = existing.result.items.find((p) => p.name === PRODUCT.name && !p.isArchived);
|
||||
|
||||
for (const product of PRODUCTS) {
|
||||
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);
|
||||
// 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(`\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) {
|
||||
console.log('\nnothing written. Re-run with --apply to create it.');
|
||||
process.exit(0);
|
||||
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) => ({
|
||||
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}`);
|
||||
}
|
||||
|
||||
console.log(`\ncreated: ${created.id}`);
|
||||
console.log(`set POLAR_PRODUCT_ID=${created.id} for the ${server} deployment.`);
|
||||
if (!apply) {
|
||||
console.log('\nnothing written. Re-run with --apply to create them.');
|
||||
}
|
||||
|
||||
@@ -137,3 +137,53 @@ describe('Webhooks', () => {
|
||||
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 { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
|
||||
import { Webhook as StandardWebhook } from 'standardwebhooks';
|
||||
import z from 'zod';
|
||||
|
||||
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 {
|
||||
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) {
|
||||
if (error instanceof WebhookVerificationError) {
|
||||
if (error instanceof WebhookVerificationError || error instanceof Error) {
|
||||
throw new VisibleError(
|
||||
'authentication',
|
||||
ErrorCodes.Authentication.INVALID_TOKEN,
|
||||
@@ -271,7 +292,7 @@ export namespace Polar {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = (event as { data?: Record<string, unknown> }).data ?? {};
|
||||
const data = event.data ?? {};
|
||||
const customer = data.customer as { externalId?: string | null } | undefined;
|
||||
// `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
|
||||
|
||||
Reference in New Issue
Block a user