diff --git a/components/iframe_forms/iframe-application-form.html b/components/iframe_forms/iframe-application-form.html index 9f3dc0e4..7c5a3f63 100644 --- a/components/iframe_forms/iframe-application-form.html +++ b/components/iframe_forms/iframe-application-form.html @@ -9,6 +9,8 @@ + +
@@ -295,6 +297,21 @@ const applicantNpub = document .getElementById("applicantNpub") .value.trim(); + + // Anti-spam: the application identifier must be a valid npub. + const applicantPubHex = + typeof window.validateNpubHex === "function" + ? window.validateNpubHex(applicantNpub) + : null; + if (!applicantPubHex) { + log( + "Please enter a valid Nostr public key (npub1…) before submitting. Your application was not sent.", + "error" + ); + document.getElementById("applicantNpub")?.focus(); + return; + } + const contactMethod = document .getElementById("contactMethod") .value.trim(); @@ -353,6 +370,7 @@ **1. Applicant Information** - **Nostr Public Key (npub):** ${applicantNpub} +- **Pubkey (hex):** ${applicantPubHex} - **Preferred Contact Method:** ${contactMethod || "N/A"} - **Username or Alias:** ${username || "N/A"} diff --git a/components/iframe_forms/iframe-bug-fix-form.html b/components/iframe_forms/iframe-bug-fix-form.html index 69109d01..13704d9a 100644 --- a/components/iframe_forms/iframe-bug-fix-form.html +++ b/components/iframe_forms/iframe-bug-fix-form.html @@ -8,6 +8,8 @@ + +
@@ -275,6 +277,22 @@ try { // Section 1: User Information const userNpub = document.getElementById("userNpub").value.trim(); + + // Anti-spam: npub is optional here, but if provided it must be a + // valid npub so the submission can't carry free-text garbage. + if ( + userNpub && + (typeof window.validateNpubHex !== "function" || + !window.validateNpubHex(userNpub)) + ) { + log( + "That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.", + "error" + ); + document.getElementById("userNpub")?.focus(); + return; + } + const roleNodes = document.querySelectorAll( 'input[name="userRole"]:checked' ); diff --git a/components/iframe_forms/iframe-feedback-form.html b/components/iframe_forms/iframe-feedback-form.html index 290acc90..5e568548 100644 --- a/components/iframe_forms/iframe-feedback-form.html +++ b/components/iframe_forms/iframe-feedback-form.html @@ -8,6 +8,8 @@ + +
@@ -212,6 +214,22 @@ try { // Section 1: User Information const userNpub = document.getElementById("userNpub").value.trim(); + + // Anti-spam: npub is optional here, but if provided it must be a + // valid npub so the submission can't carry free-text garbage. + if ( + userNpub && + (typeof window.validateNpubHex !== "function" || + !window.validateNpubHex(userNpub)) + ) { + log( + "That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.", + "error" + ); + document.getElementById("userNpub")?.focus(); + return; + } + const roleNodes = document.querySelectorAll( 'input[name="userRole"]:checked' ); diff --git a/components/iframe_forms/iframe-request-form.html b/components/iframe_forms/iframe-request-form.html index ec291d14..8c76a281 100644 --- a/components/iframe_forms/iframe-request-form.html +++ b/components/iframe_forms/iframe-request-form.html @@ -8,6 +8,8 @@ + +
@@ -235,6 +237,21 @@ try { // Section 1: User Information const userNpub = document.getElementById("userNpub").value.trim(); + + // Anti-spam: npub is optional here, but if provided it must be a + // valid npub so the submission can't carry free-text garbage. + if ( + userNpub && + (typeof window.validateNpubHex !== "function" || + !window.validateNpubHex(userNpub)) + ) { + log( + "That Nostr public key isn't valid. Enter a valid npub1… or leave it blank. Your submission was not sent.", + "error" + ); + document.getElementById("userNpub")?.focus(); + return; + } const roleNodes = document.querySelectorAll( 'input[name="userRole"]:checked' ); diff --git a/js/utils/validateNpub.js b/js/utils/validateNpub.js new file mode 100644 index 00000000..c1ca970f --- /dev/null +++ b/js/utils/validateNpub.js @@ -0,0 +1,60 @@ +// Shared, anti-spam npub (NIP-19) validation for the embed submission forms +// (application / feature-request / bug-fix / feedback). A submission identifier +// is only accepted if it decodes to a canonical 64-char hex pubkey, so the forms +// stop publishing free-text garbage. +// +// Usable two ways: +// - import { validateNpubHex } from "../utils/validateNpub.js" (tests / app) +// - as a classic global in the iframe forms: window.validateNpubHex(value) +// (the module assigns the global below and resolves nostr-tools at call time +// from window.NostrTools, which the forms already load). + +/** + * Decode a user-entered npub and return its hex pubkey, or null if it isn't a + * well-formed npub. + * + * @param {string} value Raw input (may include surrounding whitespace). + * @param {{ nip19?: { decode: Function } }} [deps] Inject a nip19 implementation + * (defaults to the global NostrTools bundle the embed forms load). + * @returns {string|null} 64-char lowercase hex pubkey, or null when invalid. + */ +export function validateNpubHex(value, { nip19 } = {}) { + const decoder = + nip19 || + (typeof globalThis !== "undefined" ? globalThis.NostrTools?.nip19 : null); + + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed.startsWith("npub1")) { + return null; + } + if (!decoder || typeof decoder.decode !== "function") { + return null; + } + + try { + const decoded = decoder.decode(trimmed); + if ( + decoded && + decoded.type === "npub" && + typeof decoded.data === "string" && + /^[0-9a-f]{64}$/.test(decoded.data) + ) { + return decoded.data; + } + } catch (error) { + return null; + } + + return null; +} + +if (typeof globalThis !== "undefined") { + // Expose to the iframe forms' classic inline scripts. Resolves NostrTools at + // call time, so load order relative to the nostr-tools bundle doesn't matter. + globalThis.validateNpubHex = (value) => validateNpubHex(value); +} + +export default validateNpubHex; diff --git a/tests/validate-npub.test.mjs b/tests/validate-npub.test.mjs new file mode 100644 index 00000000..451d035e --- /dev/null +++ b/tests/validate-npub.test.mjs @@ -0,0 +1,56 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as nostrTools from "nostr-tools"; + +import { validateNpubHex } from "../js/utils/validateNpub.js"; + +// Use the real nostr-tools nip19 so we exercise actual bech32 decoding, not a +// mirror of the implementation. Generate a real key pair for the happy path. +const nip19 = nostrTools.nip19; +const secret = nostrTools.generateSecretKey(); +const hex = nostrTools.getPublicKey(secret); +const npub = nip19.npubEncode(hex); + +test("accepts a well-formed npub and returns its lowercase hex pubkey", () => { + const result = validateNpubHex(npub, { nip19 }); + assert.equal(result, hex); + assert.match(result, /^[0-9a-f]{64}$/); +}); + +test("tolerates surrounding whitespace", () => { + assert.equal(validateNpubHex(` ${npub}\n`, { nip19 }), hex); +}); + +test("rejects free-text spam that is not an npub", () => { + for (const junk of [ + "", + " ", + "hello world", + "spammer@example.com", + "not-an-npub", + npub.replace("npub1", "note1"), // wrong NIP-19 prefix + ]) { + assert.equal(validateNpubHex(junk, { nip19 }), null, `should reject: ${junk}`); + } +}); + +test("rejects an npub with a corrupted checksum", () => { + // Flip the last character — the bech32 checksum no longer validates. + const tampered = npub.slice(0, -1) + (npub.endsWith("q") ? "p" : "q"); + assert.equal(validateNpubHex(tampered, { nip19 }), null); +}); + +test("rejects a wrong NIP-19 entity (nprofile) even though it decodes", () => { + const nprofile = nip19.nprofileEncode({ pubkey: hex, relays: [] }); + assert.equal(validateNpubHex(nprofile, { nip19 }), null); +}); + +test("rejects non-string input", () => { + for (const value of [null, undefined, 42, {}, [], true]) { + assert.equal(validateNpubHex(value, { nip19 }), null); + } +}); + +test("returns null (instead of throwing) when no decoder is available", () => { + assert.equal(validateNpubHex(npub, { nip19: null }), null); +}); diff --git a/todo/TODO_2026-06-20_pre-launch.md b/todo/TODO_2026-06-20_pre-launch.md index 59ff1b02..997bbe50 100644 --- a/todo/TODO_2026-06-20_pre-launch.md +++ b/todo/TODO_2026-06-20_pre-launch.md @@ -414,11 +414,22 @@ toward freshness and looked identical. Gave each a structural identity: Source still to pin: the loop issuing 30000/30002/30005/30015 per contact. ### 22. Anti-spam: validate npub on application / submission forms -- [ ] Application/submission forms accept too much spam. Require the submitter's - identifier to be a valid `npub` (NIP-19) — regex/decoder validation client-side - so submission only succeeds for a well-formed npub (and normalize to hex for - storage). Reject/disable submit otherwise with a clear inline error. Pairs with - #23 (the submissions should become structured data, not DMs). +- [x] **DONE 2026-06-24.** Added a shared, testable NIP-19 validator + (`js/utils/validateNpub.js`, exposes `window.validateNpubHex`) and wired it + into all four embed submission forms (`components/iframe_forms/`): + - **Application** (`applicantNpub`, required): submit is blocked unless the + npub decodes to a 64-char hex pubkey; the hex is also written into the + application body for canonical storage/whitelist matching. + - **Feature-request / bug-fix / feedback** (`userNpub`, optional): the npub + stays optional, but a non-empty value must be a valid npub or submit is + blocked with a clear inline error. + Validation decodes via the real nostr-tools `nip19` (checksum + prefix + + hex shape), so spam/free-text/`note1…`/tampered npubs are rejected. + Unit tests: `tests/validate-npub.test.mjs` (real nostr-tools). Pairs with #23 + (submissions should become structured data, not DMs). +- [ ] **VERIFY live**: open each form embed — application rejects a junk npub and + only sends with a valid npub1…; the optional forms send when the npub is + blank but reject a malformed one. ### 23. Admin submissions tab — structured submissions + approve/deny UI (SCALING) - [ ] Replace the current DM-based submission flow with **custom structured data**