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:
Wanjohi
2026-09-18 22:37:32 +00:00
committed by GitHub
8 changed files with 152 additions and 46 deletions

View File

@@ -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>,

View File

@@ -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>,
}

View File

@@ -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",
},

View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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.');
}

View File

@@ -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/);
});
});

View File

@@ -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