From 5d4621df83c3c2d3503a781d76bf7d7135dbdcd1 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 12:49:55 -0700 Subject: [PATCH 1/9] feat: add endpoint for entitlement events --- package.json | 4 +- pnpm-lock.yaml | 107 +++++++++++++++++++++++++++++++++ src/config/applicationData.ts | 96 +++++++++++++++++++++++++++++ src/interfaces/entitlement.ts | 31 ++++++++++ src/modules/discord.ts | 2 + src/modules/validateWebhook.ts | 46 ++++++++++++++ src/server/serve.ts | 87 +++++++++++++++++++++++++-- 7 files changed, 367 insertions(+), 6 deletions(-) create mode 100644 src/config/applicationData.ts create mode 100644 src/interfaces/entitlement.ts create mode 100644 src/modules/validateWebhook.ts diff --git a/package.json b/package.json index 536e3c6..7dfa4de 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ }, "dependencies": { "fastify": "5.2.1", - "nodemailer": "6.10.0" + "fastify-raw-body": "5.0.0", + "nodemailer": "6.10.0", + "tweetnacl": "1.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3a63c1..dd48888 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,15 @@ importers: fastify: specifier: 5.2.1 version: 5.2.1 + fastify-raw-body: + specifier: 5.0.0 + version: 5.0.0 nodemailer: specifier: 6.10.0 version: 6.10.0 + tweetnacl: + specifier: 1.0.3 + version: 1.0.3 devDependencies: '@nhcarrigan/eslint-config': specifier: 5.1.0 @@ -711,6 +717,10 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -823,6 +833,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1050,6 +1064,13 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} + fastify-plugin@5.0.1: + resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==} + + fastify-raw-body@5.0.0: + resolution: {integrity: sha512-2qfoaQ3BQDhZ1gtbkKZd6n0kKxJISJGM6u/skD9ljdWItAscjXrtZ1lnjr7PavmXX9j4EyCPmBDiIsLn07d5vA==} + engines: {node: '>= 10'} + fastify@5.2.1: resolution: {integrity: sha512-rslrNBF67eg8/Gyn7P2URV8/6pz8kSAscFL4EThZJ8JBMaXacVdVE4hmUcnPNKERl5o/xTiBSLfdowBRhVF1WA==} @@ -1184,6 +1205,14 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1200,6 +1229,9 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1609,6 +1641,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + raw-body@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1703,6 +1739,12 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + secure-json-parse@3.0.2: resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} @@ -1734,6 +1776,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1797,6 +1842,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} @@ -1872,6 +1921,10 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -1890,6 +1943,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1934,6 +1990,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.1.2: resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} hasBin: true @@ -2719,6 +2779,8 @@ snapshots: builtin-modules@3.3.0: {} + bytes@3.1.2: {} + cac@6.7.14: {} call-bind-apply-helpers@1.0.1: @@ -2827,6 +2889,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + depd@2.0.0: {} + dequal@2.0.3: {} dir-glob@3.0.1: @@ -3224,6 +3288,14 @@ snapshots: fast-uri@3.0.6: {} + fastify-plugin@5.0.1: {} + + fastify-raw-body@5.0.0: + dependencies: + fastify-plugin: 5.0.1 + raw-body: 3.0.0 + secure-json-parse: 2.7.0 + fastify@5.2.1: dependencies: '@fastify/ajv-compiler': 4.0.2 @@ -3382,6 +3454,18 @@ snapshots: hosted-git-info@2.8.9: {} + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + ignore@5.3.2: {} import-fresh@3.3.1: @@ -3393,6 +3477,8 @@ snapshots: indent-string@4.0.0: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3801,6 +3887,13 @@ snapshots: quick-format-unescaped@4.0.4: {} + raw-body@3.0.0: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + unpipe: 1.0.0 + react-is@16.13.1: {} react@19.0.0: {} @@ -3924,6 +4017,10 @@ snapshots: safe-stable-stringify@2.5.0: {} + safer-buffer@2.1.2: {} + + secure-json-parse@2.7.0: {} + secure-json-parse@3.0.2: {} semver@5.7.2: {} @@ -3956,6 +4053,8 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4025,6 +4124,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.1: {} + std-env@3.8.0: {} string.prototype.matchall@4.0.12: @@ -4110,6 +4211,8 @@ snapshots: toad-cache@3.7.0: {} + toidentifier@1.0.1: {} + ts-api-utils@1.4.3(typescript@5.7.3): dependencies: typescript: 5.7.3 @@ -4127,6 +4230,8 @@ snapshots: tslib@2.8.1: {} + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -4181,6 +4286,8 @@ snapshots: undici-types@6.20.0: {} + unpipe@1.0.0: {} + update-browserslist-db@1.1.2(browserslist@4.24.4): dependencies: browserslist: 4.24.4 diff --git a/src/config/applicationData.ts b/src/config/applicationData.ts new file mode 100644 index 0000000..ea8a82b --- /dev/null +++ b/src/config/applicationData.ts @@ -0,0 +1,96 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable @typescript-eslint/naming-convention -- We are using the numerical ids as the keys.*/ +export const applicationData: Record = { + "1235128719836712970": { + key: "e255cd3a348ce604e18e2f2c0f5a59a9273a420ca732630d569ef5f839f2143e", + name: "Celestine", + }, + "1338596130207957035": { + key: "ef9d2a3c8e9a4d165a12ca798be93d719dd51cc7ff901ade8136cb992cd7fe3e", + name: "Aria Iuvo", + }, + "1338664192714211459": { + key: "a28c9c2683acb209341e04d5b43bd2a24bc81f5b263095a1bf487e94ca6a3ff9", + name: "Cordelia Taryne", + }, + "1338753576583041074": { + key: "62468252371a68791c650b5aaa599ef2b7b148a7169bb5fa9f56075705ac405e", + name: "Melody Iuvo", + }, + "1343341112437248041": { + key: "3ccf23b9c10dda9a7af434e0c5fc116d05aba66caf2ebd8c738c60ba9875436f", + name: "Becca Lyria", + }, + "1343370633916059668": { + key: "a9146e39ab5f69129e3c50db5d5848c56bc4a2cb90bba10e492e08355acd3d12", + name: "Maylin Taryne", + }, + "1343413943447584819": { + key: "6d95c7132ee9f28a4ea5fffbc8dd33ecaffe0f50add0c631d5ef51cc071bd2df", + name: "Gwen Abalise", + }, + "1347642447643017289": { + key: "091be058344e543e87baafeb9fe20b004bd170ca5ee57b17afff595271080a27", + name: "Mommy", + }, + "1386862413936328796": { + key: "831e3981135a4c9d30e8c02e6f8cba73436d30c36b1a14f1fb7bc7147cd4cb75", + name: "Maribelle", + }, + "1391117878182281316": { + key: "b182039c954ba57216bf53bdc4d5ba3d89efc2b41c850cbf7f3430fbb6cc0c56", + name: "Hikari", + }, + "1391488058913718374": { + key: "fc1a838c3102351602221f2db63d997f9e4495510044d5ec436844038b9a5bd5", + name: "Rosalia Nightsong", + }, + "1391489982887362761": { + key: "ef173a181d6fcf506b72795812fca8ecc3a81c2ff382bfd39564bab63fd90eee", + name: "Sorielle", + }, + "1391491102657482863": { + key: "a412583e5d5510aebb6d54eb49f36797e62f9567efd79108863adbb93cf00236", + name: "Verena", + }, + "1391492296222179459": { + key: "aab61af4411fc1dc710d5b5aba5d16fba9effaad9415b878a3f1595428dbebcd", + name: "Liora", + }, + "1391493722176356434": { + key: "3dc2abd018271a5a1f65c09597738a711b2fc619d34da59189279d6c2a1922f4", + name: "Thessalia", + }, + "1391494389477412906": { + key: "8d11517cf954779e4645b8c10432697a0572dcfcecc0bb14d0fe2397f5a03453", + name: "Callista", + }, + "1391495288421879849": { + key: "fb0894e2dcd3e736addd70a675b40d5681d39c133289cdfe4f7378006163e817", + name: "Eirene", + }, + "1391497269177483405": { + key: "0887f7e12053d559f8944a06ed323ec237d34f8e15860b672fb7abe6dc32d192", + name: "Sybil", + }, + "1391503229287928000": { + key: "8a4d4f18dae4e2d0ec610e3da47254a812c39cfac6ee986cfaa20eb848a2db92", + name: "Clarion", + }, + "1391503834073006181": { + key: "60ffe0a3503d5166c1e4b7370753793dff60d75a28ebf4ace8b6623ad5207821", + name: "Evangeline", + }, + "1391504577811185744": { + key: "33bb085873b46199facfbfae00b7d71ed04d55377deeaa2a0c8afa303c68a75d", + name: "Theodora", + }, + "1391505285465509978": { + key: "edce882890078d52c00c653b9e305565a909a0807abf86b36f1d7679434b80fa", + name: "Veluna", + }, +}; diff --git a/src/interfaces/entitlement.ts b/src/interfaces/entitlement.ts new file mode 100644 index 0000000..abe43b8 --- /dev/null +++ b/src/interfaces/entitlement.ts @@ -0,0 +1,31 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +/* eslint-disable @typescript-eslint/naming-convention -- Needs to match Discord's structure. */ +export interface Entitlement { + application_id: string; + event: { + data: { + application_id: string; + consumed: boolean; + deleted: boolean; + ends_at: string; + gift_code_flags: number; + guild_id: string; + id: string; + promotion_id: string | null; + sku_id: string; + starts_at: string; + subscription_id: string; + type: number; + user_id: string; + }; + timestamp: string; + type: string; + }; + type: number; + version: number; +} diff --git a/src/modules/discord.ts b/src/modules/discord.ts index e303e21..89ff13e 100644 --- a/src/modules/discord.ts +++ b/src/modules/discord.ts @@ -17,6 +17,7 @@ export const sendDiscord = async( body: JSON.stringify({ components: [ { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Needs to match Discord's structure. accent_color: 15_418_782, components: [ { @@ -40,6 +41,7 @@ export const sendDiscord = async( flags: 32_768, }), headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Needs to match Discord's structure. "Content-Type": "application/json", }, method: "POST", diff --git a/src/modules/validateWebhook.ts b/src/modules/validateWebhook.ts new file mode 100644 index 0000000..e240259 --- /dev/null +++ b/src/modules/validateWebhook.ts @@ -0,0 +1,46 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import nacl from "tweetnacl"; +import { applicationData } from "../config/applicationData.js"; +import type { Entitlement } from "../interfaces/entitlement.js"; +import type { FastifyRequest } from "fastify"; + +/** + * Validates that the webhook request is from Discord by checking the signature. + * @param request - The Fastify request object containing the webhook data. + * @returns A boolean indicating whether the webhook signature is valid. + */ +export const validateWebhook = ( + // eslint-disable-next-line @typescript-eslint/naming-convention -- Body must be capitalised for Fastify. + request: FastifyRequest<{ Body: Entitlement }>, +): boolean => { + const { application_id: applicationId } = request.body; + const appData = applicationData[applicationId]; + if (appData === undefined) { + return false; + } + const signature = request.headers["x-signature-ed25519"]; + const timestamp = request.headers["x-signature-timestamp"]; + const { rawBody } = request; + if ( + signature === undefined + || timestamp === undefined + || rawBody === undefined + ) { + return false; + } + return nacl.sign.detached.verify( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- Being lazy here, tbh. + Buffer.from(`${timestamp}${rawBody}`), + Buffer.from( + Array.isArray(signature) + ? signature[0] ?? signature.join("") + : signature, + "hex", + ), + Buffer.from(process.env.DISCORD_PUBLIC_KEY ?? "", "hex"), + ); +}; diff --git a/src/server/serve.ts b/src/server/serve.ts index f2aadb5..e789bc7 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -5,12 +5,16 @@ */ import fastify from "fastify"; +import fastifyRawBody from "fastify-raw-body"; +import { applicationData } from "../config/applicationData.js"; import { auth } from "../modules/auth.js"; import { sendDiscord } from "../modules/discord.js"; import { sendMail } from "../modules/sendMail.js"; +import { validateWebhook } from "../modules/validateWebhook.js"; import { errorSchema } from "../schemas/errorSchema.js"; import { logSchema } from "../schemas/logSchema.js"; import { uptimeSchema } from "../schemas/uptimeSchema.js"; +import type { Entitlement } from "../interfaces/entitlement.js"; import type { Error } from "../interfaces/error.js"; import type { Log } from "../interfaces/log.js"; import type { Uptime } from "../interfaces/uptime.js"; @@ -63,6 +67,15 @@ export const instantiateServer = (): void => { logger: false, }); + server.register(fastifyRawBody, { + encoding: "utf8", + field: "rawBody", + global: false, + jsonContentTypes: [], + routes: [], + runFirst: true, + }); + server.get("/", (_request, response) => { response.header("Content-Type", "text/html"); response.send(html); @@ -90,7 +103,10 @@ export const instantiateServer = (): void => { return; } const { application, context, stack, message } = request.body; - await sendMail(`[ERROR]: ${context} - ${application}`, `${message}\n\n${stack}`); + await sendMail( + `[ERROR]: ${context} - ${application}`, + `${message}\n\n${stack}`, + ); await sendDiscord( `[ERROR]: ${context} - ${application}`, `${message}\n\n\`\`\`\n${stack}\n\`\`\``, @@ -115,13 +131,68 @@ export const instantiateServer = (): void => { }, ); + // eslint-disable-next-line @typescript-eslint/naming-convention -- Body must be capitalised for Fastify. + server.post<{ Body: Entitlement }>( + "/entitlement", + { config: { rawBody: true } }, + async(request, response) => { + if (!validateWebhook(request)) { + await response.status(401).send({ success: false }); + void sendDiscord( + "[NOTIFICATION]: Entitlement Event", + "An invalid webhook signature was received.", + ); + void sendMail( + "[NOTIFICATION]: Entitlement Event", + "An invalid webhook signature was received.", + ); + return; + } + const { type } = request.body; + if (type === 0) { + await response.status(204).send(); + void sendDiscord( + "[NOTIFICATION]: Entitlement Event", + "Received a ping from Discord.", + ); + void sendMail( + "[NOTIFICATION]: Entitlement Event", + "Received a ping from Discord.", + ); + return; + } + await response.status(204).send(); + const { application_id: applicationId, event } = request.body; + const { + user_id: userId, + guild_id: guildId, + ends_at: endsAt, + } = event.data; + const appInfo = applicationData[applicationId]; + await sendDiscord( + `[ENTITLEMENT]: ${appInfo?.name ?? applicationId}`, + `Entitlement purchased!\n- **User ID**: ${userId}\n- **Guild ID**: ${guildId}\n- **Ends At**: ${endsAt}`, + ); + await sendMail( + `[ENTITLEMENT]: ${appInfo?.name ?? applicationId}`, + `Entitlement purchased!\n- **User ID**: ${userId}\n- **Guild ID**: ${guildId}\n- **Ends At**: ${endsAt}`, + ); + }, + ); + server.listen({ port: 5003 }, (error) => { const application = "Alert Server"; if (error) { const { message, stack } = error; const context = "Server Startup"; - void sendMail(`[ERROR]: ${context} - ${application}`, `${message}\n\n${String(stack)}`); - void sendDiscord(`[ERROR]: ${context} - ${application}`, `${message}\n\n\`\`\`\n${String(stack)}\n\`\`\``); + void sendMail( + `[ERROR]: ${context} - ${application}`, + `${message}\n\n${String(stack)}`, + ); + void sendDiscord( + `[ERROR]: ${context} - ${application}`, + `${message}\n\n\`\`\`\n${String(stack)}\n\`\`\``, + ); return; } const level = "debug"; @@ -134,7 +205,13 @@ export const instantiateServer = (): void => { const context = "Server Startup"; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Totally being lazy. const { message, stack } = error as Error; - void sendMail(`[ERROR]: ${context} - ${application}`, `${message}\n\n${stack}`); - void sendDiscord(`[ERROR]: ${context} - ${application}`, `${message}\n\n\`\`\`\n${stack}\n\`\`\``); + void sendMail( + `[ERROR]: ${context} - ${application}`, + `${message}\n\n${stack}`, + ); + void sendDiscord( + `[ERROR]: ${context} - ${application}`, + `${message}\n\n\`\`\`\n${stack}\n\`\`\``, + ); } }; -- 2.49.0 From a3d0753a66eef03e0f0adbd47035a42eb325ab42 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 12:53:21 -0700 Subject: [PATCH 2/9] feat: add more logging --- src/modules/validateWebhook.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/modules/validateWebhook.ts b/src/modules/validateWebhook.ts index e240259..f261c04 100644 --- a/src/modules/validateWebhook.ts +++ b/src/modules/validateWebhook.ts @@ -5,6 +5,7 @@ */ import nacl from "tweetnacl"; import { applicationData } from "../config/applicationData.js"; +import { sendDiscord } from "./discord.js"; import type { Entitlement } from "../interfaces/entitlement.js"; import type { FastifyRequest } from "fastify"; @@ -20,6 +21,7 @@ export const validateWebhook = ( const { application_id: applicationId } = request.body; const appData = applicationData[applicationId]; if (appData === undefined) { + void sendDiscord(`[NOTIFICATION]: Invalid Application ID`, `Received an entitlement event for an invalid application ID: ${applicationId}`); return false; } const signature = request.headers["x-signature-ed25519"]; @@ -30,9 +32,13 @@ export const validateWebhook = ( || timestamp === undefined || rawBody === undefined ) { + void sendDiscord( + `[NOTIFICATION]: Invalid Webhook Signature`, + `Received an entitlement event with a missing signature or timestamp.\nApplication ID: ${applicationId}\nSignature: ${signature}\nTimestamp: ${timestamp}\nRaw Body: ${rawBody}`, + ); return false; } - return nacl.sign.detached.verify( + const isValid = nacl.sign.detached.verify( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- Being lazy here, tbh. Buffer.from(`${timestamp}${rawBody}`), Buffer.from( @@ -43,4 +49,11 @@ export const validateWebhook = ( ), Buffer.from(process.env.DISCORD_PUBLIC_KEY ?? "", "hex"), ); + if (!isValid) { + void sendDiscord( + `[NOTIFICATION]: Invalid Webhook Signature`, + `Received an entitlement event with an invalid signature.\nApplication ID: ${applicationId}\nSignature: ${signature}\nTimestamp: ${timestamp}\nRaw Body: ${rawBody}`, + ); + } + return isValid; }; -- 2.49.0 From 17cfb11e8abad8d786eb65af8abdf3c6d9ead2ee Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 12:54:42 -0700 Subject: [PATCH 3/9] fix: raw body config --- src/server/serve.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/server/serve.ts b/src/server/serve.ts index e789bc7..7c66c81 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -68,12 +68,10 @@ export const instantiateServer = (): void => { }); server.register(fastifyRawBody, { - encoding: "utf8", - field: "rawBody", - global: false, - jsonContentTypes: [], - routes: [], - runFirst: true, + encoding: "utf8", + field: "rawBody", + global: false, + runFirst: true, }); server.get("/", (_request, response) => { -- 2.49.0 From c0a276947af59fd2f2be1424e3eba252539b0d25 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 12:55:30 -0700 Subject: [PATCH 4/9] fix: make it global? --- src/server/serve.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/serve.ts b/src/server/serve.ts index 7c66c81..c95d717 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -70,7 +70,7 @@ export const instantiateServer = (): void => { server.register(fastifyRawBody, { encoding: "utf8", field: "rawBody", - global: false, + global: true, runFirst: true, }); -- 2.49.0 From 63de0cbb7e2d3df729ce7366caa2cc5ec1e6f0eb Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 12:57:42 -0700 Subject: [PATCH 5/9] fix: does it work with body? --- src/modules/validateWebhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/validateWebhook.ts b/src/modules/validateWebhook.ts index f261c04..ad461b9 100644 --- a/src/modules/validateWebhook.ts +++ b/src/modules/validateWebhook.ts @@ -26,7 +26,7 @@ export const validateWebhook = ( } const signature = request.headers["x-signature-ed25519"]; const timestamp = request.headers["x-signature-timestamp"]; - const { rawBody } = request; + const rawBody = request.rawBody ?? request.body; if ( signature === undefined || timestamp === undefined -- 2.49.0 From 2e2459d8a56d689f6392f645657b65133e84605e Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 13:10:34 -0700 Subject: [PATCH 6/9] feat: try parsing it ourselves --- package.json | 2 +- pnpm-lock.yaml | 107 +++------------------------------ src/modules/validateWebhook.ts | 6 +- src/server/serve.ts | 11 +--- 4 files changed, 14 insertions(+), 112 deletions(-) diff --git a/package.json b/package.json index 7dfa4de..071edfa 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ }, "dependencies": { "fastify": "5.2.1", - "fastify-raw-body": "5.0.0", "nodemailer": "6.10.0", + "secure-json-parse": "4.0.0", "tweetnacl": "1.0.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd48888..20a703b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,12 +11,12 @@ importers: fastify: specifier: 5.2.1 version: 5.2.1 - fastify-raw-body: - specifier: 5.0.0 - version: 5.0.0 nodemailer: specifier: 6.10.0 version: 6.10.0 + secure-json-parse: + specifier: 4.0.0 + version: 4.0.0 tweetnacl: specifier: 1.0.3 version: 1.0.3 @@ -717,10 +717,6 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -833,10 +829,6 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1064,13 +1056,6 @@ packages: fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} - fastify-plugin@5.0.1: - resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==} - - fastify-raw-body@5.0.0: - resolution: {integrity: sha512-2qfoaQ3BQDhZ1gtbkKZd6n0kKxJISJGM6u/skD9ljdWItAscjXrtZ1lnjr7PavmXX9j4EyCPmBDiIsLn07d5vA==} - engines: {node: '>= 10'} - fastify@5.2.1: resolution: {integrity: sha512-rslrNBF67eg8/Gyn7P2URV8/6pz8kSAscFL4EThZJ8JBMaXacVdVE4hmUcnPNKERl5o/xTiBSLfdowBRhVF1WA==} @@ -1205,14 +1190,6 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} - http-errors@2.0.0: - resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} - engines: {node: '>= 0.8'} - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1229,9 +1206,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -1641,10 +1615,6 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} - raw-body@3.0.0: - resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} - engines: {node: '>= 0.8'} - react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1739,15 +1709,12 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - secure-json-parse@3.0.2: resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} + secure-json-parse@4.0.0: + resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -1776,9 +1743,6 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1842,10 +1806,6 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - statuses@2.0.1: - resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} - engines: {node: '>= 0.8'} - std-env@3.8.0: resolution: {integrity: sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==} @@ -1921,10 +1881,6 @@ packages: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -1990,10 +1946,6 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - update-browserslist-db@1.1.2: resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} hasBin: true @@ -2779,8 +2731,6 @@ snapshots: builtin-modules@3.3.0: {} - bytes@3.1.2: {} - cac@6.7.14: {} call-bind-apply-helpers@1.0.1: @@ -2889,8 +2839,6 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - depd@2.0.0: {} - dequal@2.0.3: {} dir-glob@3.0.1: @@ -3288,14 +3236,6 @@ snapshots: fast-uri@3.0.6: {} - fastify-plugin@5.0.1: {} - - fastify-raw-body@5.0.0: - dependencies: - fastify-plugin: 5.0.1 - raw-body: 3.0.0 - secure-json-parse: 2.7.0 - fastify@5.2.1: dependencies: '@fastify/ajv-compiler': 4.0.2 @@ -3454,18 +3394,6 @@ snapshots: hosted-git-info@2.8.9: {} - http-errors@2.0.0: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.1 - toidentifier: 1.0.1 - - iconv-lite@0.6.3: - dependencies: - safer-buffer: 2.1.2 - ignore@5.3.2: {} import-fresh@3.3.1: @@ -3477,8 +3405,6 @@ snapshots: indent-string@4.0.0: {} - inherits@2.0.4: {} - internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -3887,13 +3813,6 @@ snapshots: quick-format-unescaped@4.0.4: {} - raw-body@3.0.0: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.0 - iconv-lite: 0.6.3 - unpipe: 1.0.0 - react-is@16.13.1: {} react@19.0.0: {} @@ -4017,12 +3936,10 @@ snapshots: safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: {} - - secure-json-parse@2.7.0: {} - secure-json-parse@3.0.2: {} + secure-json-parse@4.0.0: {} + semver@5.7.2: {} semver@6.3.1: {} @@ -4053,8 +3970,6 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.1 - setprototypeof@1.2.0: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -4124,8 +4039,6 @@ snapshots: stackback@0.0.2: {} - statuses@2.0.1: {} - std-env@3.8.0: {} string.prototype.matchall@4.0.12: @@ -4211,8 +4124,6 @@ snapshots: toad-cache@3.7.0: {} - toidentifier@1.0.1: {} - ts-api-utils@1.4.3(typescript@5.7.3): dependencies: typescript: 5.7.3 @@ -4286,8 +4197,6 @@ snapshots: undici-types@6.20.0: {} - unpipe@1.0.0: {} - update-browserslist-db@1.1.2(browserslist@4.24.4): dependencies: browserslist: 4.24.4 diff --git a/src/modules/validateWebhook.ts b/src/modules/validateWebhook.ts index ad461b9..dc73686 100644 --- a/src/modules/validateWebhook.ts +++ b/src/modules/validateWebhook.ts @@ -3,6 +3,7 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +import secureJson from "secure-json-parse"; import nacl from "tweetnacl"; import { applicationData } from "../config/applicationData.js"; import { sendDiscord } from "./discord.js"; @@ -26,7 +27,8 @@ export const validateWebhook = ( } const signature = request.headers["x-signature-ed25519"]; const timestamp = request.headers["x-signature-timestamp"]; - const rawBody = request.rawBody ?? request.body; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Later. + const rawBody = secureJson.parse(JSON.stringify(request.body)); if ( signature === undefined || timestamp === undefined @@ -47,7 +49,7 @@ export const validateWebhook = ( : signature, "hex", ), - Buffer.from(process.env.DISCORD_PUBLIC_KEY ?? "", "hex"), + Buffer.from(appData.key, "hex"), ); if (!isValid) { void sendDiscord( diff --git a/src/server/serve.ts b/src/server/serve.ts index c95d717..2435863 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -5,7 +5,6 @@ */ import fastify from "fastify"; -import fastifyRawBody from "fastify-raw-body"; import { applicationData } from "../config/applicationData.js"; import { auth } from "../modules/auth.js"; import { sendDiscord } from "../modules/discord.js"; @@ -67,13 +66,6 @@ export const instantiateServer = (): void => { logger: false, }); - server.register(fastifyRawBody, { - encoding: "utf8", - field: "rawBody", - global: true, - runFirst: true, - }); - server.get("/", (_request, response) => { response.header("Content-Type", "text/html"); response.send(html); @@ -146,9 +138,9 @@ export const instantiateServer = (): void => { ); return; } + await response.status(204).send(); const { type } = request.body; if (type === 0) { - await response.status(204).send(); void sendDiscord( "[NOTIFICATION]: Entitlement Event", "Received a ping from Discord.", @@ -159,7 +151,6 @@ export const instantiateServer = (): void => { ); return; } - await response.status(204).send(); const { application_id: applicationId, event } = request.body; const { user_id: userId, -- 2.49.0 From 0279b5ab4b65874e76fd93bc73aa971502f53eec Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 13:22:05 -0700 Subject: [PATCH 7/9] feat: use discord-verify --- package.json | 5 +- pnpm-lock.yaml | 99 ++++++++++++++++++++++++++++------ src/modules/validateWebhook.ts | 45 ++++++---------- src/server/serve.ts | 23 +++++++- 4 files changed, 123 insertions(+), 49 deletions(-) diff --git a/package.json b/package.json index 071edfa..319d300 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,8 @@ "typescript": "5.7.3" }, "dependencies": { + "discord-verify": "1.2.0", "fastify": "5.2.1", - "nodemailer": "6.10.0", - "secure-json-parse": "4.0.0", - "tweetnacl": "1.0.3" + "nodemailer": "6.10.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20a703b..79d9290 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,15 @@ importers: .: dependencies: + discord-verify: + specifier: 1.2.0 + version: 1.2.0 fastify: specifier: 5.2.1 version: 5.2.1 nodemailer: specifier: 6.10.0 version: 6.10.0 - secure-json-parse: - specifier: 4.0.0 - version: 4.0.0 - tweetnacl: - specifier: 1.0.3 - version: 1.0.3 devDependencies: '@nhcarrigan/eslint-config': specifier: 5.1.0 @@ -438,15 +435,33 @@ packages: peerDependencies: eslint: '>=8.40.0' + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express@4.17.23': + resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node@22.13.1': resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==} @@ -456,6 +471,18 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.5': + resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} + + '@types/serve-static@1.15.8': + resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@typescript-eslint/eslint-plugin@8.19.0': resolution: {integrity: sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -837,6 +864,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + discord-verify@1.2.0: + resolution: {integrity: sha512-8qlrMROW8DhpzWWzgNq9kpeLDxKanWa4EDVoj/ASVv2nr+dSr4JPmu2tFSydf3hAGI/OIJTnZyD0JulMYIxx4w==} + engines: {node: '>=16'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1712,9 +1743,6 @@ packages: secure-json-parse@3.0.2: resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} - secure-json-parse@4.0.0: - resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} - semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -1899,9 +1927,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tweetnacl@1.0.3: - resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2370,12 +2395,39 @@ snapshots: - supports-color - typescript + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.13.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.13.1 + '@types/estree@1.0.6': {} + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 22.13.1 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.5 + + '@types/express@4.17.23': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.8 + + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/mime@1.3.5': {} + '@types/node@22.13.1': dependencies: undici-types: 6.20.0 @@ -2386,6 +2438,21 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.5': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.13.1 + + '@types/serve-static@1.15.8': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.13.1 + '@types/send': 0.17.5 + '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.20.0)(typescript@5.7.3))(eslint@9.20.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -2845,6 +2912,10 @@ snapshots: dependencies: path-type: 4.0.0 + discord-verify@1.2.0: + dependencies: + '@types/express': 4.17.23 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3938,8 +4009,6 @@ snapshots: secure-json-parse@3.0.2: {} - secure-json-parse@4.0.0: {} - semver@5.7.2: {} semver@6.3.1: {} @@ -4141,8 +4210,6 @@ snapshots: tslib@2.8.1: {} - tweetnacl@1.0.3: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/modules/validateWebhook.ts b/src/modules/validateWebhook.ts index dc73686..030b640 100644 --- a/src/modules/validateWebhook.ts +++ b/src/modules/validateWebhook.ts @@ -3,8 +3,8 @@ * @license Naomi's Public License * @author Naomi Carrigan */ -import secureJson from "secure-json-parse"; -import nacl from "tweetnacl"; +import { webcrypto } from "node:crypto"; +import { verify } from "discord-verify/node"; import { applicationData } from "../config/applicationData.js"; import { sendDiscord } from "./discord.js"; import type { Entitlement } from "../interfaces/entitlement.js"; @@ -15,10 +15,15 @@ import type { FastifyRequest } from "fastify"; * @param request - The Fastify request object containing the webhook data. * @returns A boolean indicating whether the webhook signature is valid. */ -export const validateWebhook = ( +export const validateWebhook = async( // eslint-disable-next-line @typescript-eslint/naming-convention -- Body must be capitalised for Fastify. - request: FastifyRequest<{ Body: Entitlement }>, -): boolean => { + request: FastifyRequest<{ Body: Entitlement; Headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Header must be formatted for Fastify. + "x-signature-ed25519": string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- Header must be formatted for Fastify. + "x-signature-timestamp": string; + }; }>, +): Promise => { const { application_id: applicationId } = request.body; const appData = applicationData[applicationId]; if (appData === undefined) { @@ -27,29 +32,13 @@ export const validateWebhook = ( } const signature = request.headers["x-signature-ed25519"]; const timestamp = request.headers["x-signature-timestamp"]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Later. - const rawBody = secureJson.parse(JSON.stringify(request.body)); - if ( - signature === undefined - || timestamp === undefined - || rawBody === undefined - ) { - void sendDiscord( - `[NOTIFICATION]: Invalid Webhook Signature`, - `Received an entitlement event with a missing signature or timestamp.\nApplication ID: ${applicationId}\nSignature: ${signature}\nTimestamp: ${timestamp}\nRaw Body: ${rawBody}`, - ); - return false; - } - const isValid = nacl.sign.detached.verify( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- Being lazy here, tbh. - Buffer.from(`${timestamp}${rawBody}`), - Buffer.from( - Array.isArray(signature) - ? signature[0] ?? signature.join("") - : signature, - "hex", - ), - Buffer.from(appData.key, "hex"), + const rawBody = JSON.stringify(request.body); + const isValid = await verify( + rawBody, + signature, + timestamp, + appData.key, + webcrypto.subtle, ); if (!isValid) { void sendDiscord( diff --git a/src/server/serve.ts b/src/server/serve.ts index 2435863..e071b1c 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -122,10 +122,29 @@ export const instantiateServer = (): void => { ); // eslint-disable-next-line @typescript-eslint/naming-convention -- Body must be capitalised for Fastify. - server.post<{ Body: Entitlement }>( + server.post<{ Body: Entitlement; Headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Header must be formatted for Fastify. + "x-signature-ed25519": string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- Header must be formatted for Fastify. + "x-signature-timestamp": string; + }; }>( "/entitlement", - { config: { rawBody: true } }, async(request, response) => { + const signature = req.headers["x-signature-ed25519"]; + const timestamp = req.headers["x-signature-timestamp"]; + const rawBody = JSON.stringify(req.body); + + const isValid = await verify( + rawBody, + signature, + timestamp, + this.client.publicKey, + crypto.webcrypto.subtle, + ); + + if (!isValid) { + return res.code(401).send("Invalid signature"); + } if (!validateWebhook(request)) { await response.status(401).send({ success: false }); void sendDiscord( -- 2.49.0 From 28dda101d73eb10de7b483f920bb025704aeace6 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 13:36:24 -0700 Subject: [PATCH 8/9] fix: forgot to save --- src/server/serve.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/server/serve.ts b/src/server/serve.ts index e071b1c..ae1aa0c 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -130,22 +130,8 @@ export const instantiateServer = (): void => { }; }>( "/entitlement", async(request, response) => { - const signature = req.headers["x-signature-ed25519"]; - const timestamp = req.headers["x-signature-timestamp"]; - const rawBody = JSON.stringify(req.body); - - const isValid = await verify( - rawBody, - signature, - timestamp, - this.client.publicKey, - crypto.webcrypto.subtle, - ); - + const isValid = await validateWebhook(request); if (!isValid) { - return res.code(401).send("Invalid signature"); - } - if (!validateWebhook(request)) { await response.status(401).send({ success: false }); void sendDiscord( "[NOTIFICATION]: Entitlement Event", -- 2.49.0 From 0d50029e0cf0d9d9f1feb4a6da4737dcb63d922a Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Sun, 6 Jul 2025 13:38:34 -0700 Subject: [PATCH 9/9] chore: cleanup --- src/server/serve.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/server/serve.ts b/src/server/serve.ts index ae1aa0c..b6660c3 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -129,40 +129,40 @@ export const instantiateServer = (): void => { "x-signature-timestamp": string; }; }>( "/entitlement", + // eslint-disable-next-line complexity -- Fuck off. async(request, response) => { + const { type, application_id: applicationId, event } = request.body; + const appInfo = applicationData[applicationId]; const isValid = await validateWebhook(request); if (!isValid) { await response.status(401).send({ success: false }); void sendDiscord( - "[NOTIFICATION]: Entitlement Event", + `[ENTITLEMENT]: ${appInfo?.name ?? applicationId}`, "An invalid webhook signature was received.", ); void sendMail( - "[NOTIFICATION]: Entitlement Event", + `[ENTITLEMENT]: ${appInfo?.name ?? applicationId}`, "An invalid webhook signature was received.", ); return; } await response.status(204).send(); - const { type } = request.body; if (type === 0) { void sendDiscord( - "[NOTIFICATION]: Entitlement Event", + `[ENTITLEMENT]: ${appInfo?.name ?? applicationId}`, "Received a ping from Discord.", ); void sendMail( - "[NOTIFICATION]: Entitlement Event", + `[ENTITLEMENT]: ${appInfo?.name ?? applicationId}`, "Received a ping from Discord.", ); return; } - const { application_id: applicationId, event } = request.body; const { user_id: userId, guild_id: guildId, ends_at: endsAt, } = event.data; - const appInfo = applicationData[applicationId]; await sendDiscord( `[ENTITLEMENT]: ${appInfo?.name ?? applicationId}`, `Entitlement purchased!\n- **User ID**: ${userId}\n- **Guild ID**: ${guildId}\n- **Ends At**: ${endsAt}`, -- 2.49.0