123456789101112import { copyToClipboard } from "./clipboard";13import { setupAutoLock, touchAutoLock } from "./alarms";14import * as vault from "./vault";15import {16 activateLicense,17 deactivateLicense,18 getLicenseInfo,19 getLicenseKey,20 isProUser,21 markCelebrationPlayed,22 shouldPlayCelebration,23} from "./license";24import {25 cancelFirefoxPasskeyCreate,26 completeFirefoxPasskeyCreate,27 getPasskeyProviderState,28 getPendingPasskeyRequest,29 handleFirefoxPasskeyCreate,30 handleFirefoxPasskeyGet,31 handlePasskeyHasCredentials,32 initiateFirefoxPasskeyCreate,33 isPasskeyOfferDeclined,34 recordPasskeyOfferDecline,35 resolvePendingPasskeyRequest,36 setPasskeyProviderEnabled,37} from "./passkey";38import { buildAdvancedHealthReport, resolveResetUrl } from "./health";39import type {40 Message,41 MessageResponse,42 MessageType,43 ProFeature,44} from "../shared/types";45import { isPasskeyOnly } from "../shared/types";46import type { SyncStatus } from "../shared/sync-types";4748495051525354async function requirePro(55 feature: ProFeature,56 trigger: "user" | "background" = "user"57): Promise<{ allowed: boolean }> {58 if (await isProUser()) return { allowed: true };5960 if (trigger === "user") {61 62 chrome.runtime63 .sendMessage({ type: "PRO_GATE_TRIGGERED", payload: { feature } })64 .catch(() => {65 66 });67 } else {68 69 70 71 if (chrome.storage.session) {72 chrome.storage.session73 .set({ pendingProNotice: { feature, triggeredAt: Date.now() } })74 .catch(() => {});75 }76 }7778 return { allowed: false };79}8081const PRIVATE_IPV4_RANGES = [82 { prefix: "10." },83 { prefix: "127." },84 { prefix: "169.254." },85 { prefix: "192.168." },86];878889909192const FEEDBACK_ENDPOINT = "";9394export function setupMessageHandler(): void {95 chrome.runtime.onMessage.addListener(96 (msg: unknown, sender, sendResponse) => {97 if (!isMessage(msg)) {98 sendResponse({ ok: false, error: "invalid message" });99 return true;100 }101 handleMessage(msg, sender)102 .then(sendResponse)103 .catch((err) =>104 sendResponse({105 ok: false,106 error: err instanceof Error ? err.message : String(err),107 })108 );109 return true;110 }111 );112}113114function isMessage(m: unknown): m is Message {115 return typeof m === "object" && m !== null && "type" in m;116}117118async function handleMessage(119 msg: Message,120 sender: chrome.runtime.MessageSender121): Promise<MessageResponse> {122 try {123 124 125 126 127 if (sender.id !== undefined && sender.id !== chrome.runtime.id) {128 return { ok: false, error: "invalid sender" };129 }130131 if (shouldRefreshAutoLock(msg.type) && vault.isUnlocked()) {132 touchAutoLock();133 }134135 switch (msg.type) {136 case "CREATE_VAULT": {137 const { password } = msg.payload as { password: string };138 await vault.createVault(password);139 await setupAutoLock();140 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });141 return { ok: true };142 }143 case "UNLOCK": {144 const { password } = msg.payload as { password: string };145 const ok = await vault.unlock(password);146 if (ok) {147 await setupAutoLock();148 broadcastToContentScripts({ type: "VAULT_UNLOCKED" });149 }150 return { ok };151 }152 case "LOCK":153 vault.lock();154 return { ok: true };155 case "IS_UNLOCKED":156 return { ok: true, data: vault.isUnlocked() };157 case "GET_VAULT_STATUS":158 return { ok: true, unlocked: vault.isUnlocked() };159 case "HAS_VAULT":160 return { ok: true, data: await vault.hasVault() };161 case "GET_ENTRIES":162 return { ok: true, data: vault.getEntries() };163 case "ADD_ENTRY": {164 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };165 const entry = await vault.addEntry(msg.payload as any);166 return { ok: true, data: entry };167 }168 case "BATCH_ADD_ENTRIES": {169 const { entries } = msg.payload as { entries: any[] };170 const added = await vault.batchAddEntries(entries);171 return { ok: true, data: added };172 }173 case "RESTORE_ENTRIES": {174 const { entries, mode, settings } = msg.payload as {175 entries: any[];176 mode: "replace" | "merge";177 settings?: vault.VaultSettings;178 };179 const summary = await vault.restoreEntries(entries, mode);180 if (settings && typeof settings === "object") {181 await vault.updateSettings(settings);182 }183 return { ok: true, data: summary };184 }185 case "CLEAR_VAULT_ENTRIES": {186 await vault.clearVaultEntries("REPLACE_ALL");187 return { ok: true };188 }189 case "LIST_VAULT_SNAPSHOTS": {190 const snapshots = await vault.listSnapshots();191 return { ok: true, data: snapshots };192 }193 case "RESTORE_VAULT_SNAPSHOT": {194 const { index, password } = msg.payload as { index: number; password: string };195 const ok = await vault.restoreFromSnapshot(index, password);196 if (ok) broadcastToContentScripts({ type: "VAULT_UNLOCKED" });197 return { ok };198 }199 case "COUNT_DUPLICATES": {200 return { ok: true, data: vault.countDuplicates() };201 }202 case "DEDUPLICATE_ENTRIES": {203 const removed = await vault.deduplicateEntries();204 return { ok: true, data: removed };205 }206 case "UPDATE_ENTRY": {207 const { id, patch } = msg.payload as any;208 await vault.updateEntry(id, patch);209 return { ok: true };210 }211 case "ADD_ADDITIONAL_URL": {212 const { id, url } = msg.payload as { id: string; url: string };213 const trimmed = typeof url === "string" ? url.trim() : "";214 if (!trimmed) return { ok: false, error: "url required" };215 const existing = vault.getEntryById(id);216 if (!existing) return { ok: false, error: "entry not found" };217 if (existing.type !== "login") return { ok: false, error: "login entries only" };218 const merged = [...(existing.additionalUrls ?? []), trimmed];219 await vault.updateEntry(id, { additionalUrls: merged });220 return { ok: true };221 }222 case "DELETE_ENTRY": {223 const { id } = msg.payload as { id: string };224 await vault.deleteEntry(id);225 return { ok: true };226 }227 case "GET_DELETED_ENTRIES":228 return { ok: true, data: vault.getDeletedEntries() };229 case "RESTORE_ENTRY": {230 const { id } = msg.payload as { id: string };231 await vault.restoreEntry(id);232 return { ok: true };233 }234 case "PURGE_ENTRY": {235 const { id } = msg.payload as { id: string };236 await vault.purgeEntry(id);237 return { ok: true };238 }239 case "PURGE_EXPIRED_TRASH": {240 const { retentionMs } = msg.payload as { retentionMs: number };241 const purged = await vault.purgeExpiredTrash(retentionMs);242 return { ok: true, data: purged };243 }244 case "AUTOFILL_QUERY": {245 if (!vault.isUnlocked()) {246 return { ok: false, error: "VAULT_LOCKED" };247 }248 const { domain, includeAll, kind } = msg.payload as {249 domain: string;250 includeAll?: boolean;251 kind?: "login" | "card";252 };253 254 255 256 257 if (kind === "card") {258 const cards = vault.getEntries().filter((e) => e.type === "card");259 return {260 ok: true,261 data: cards.map((e) => ({262 id: e.id,263 title: e.cardNickname || e.title,264 cardholderName: e.cardholderName,265 last4: e.last4,266 expMonth: e.expMonth,267 expYear: e.expYear,268 network: e.network,269 })),270 };271 }272 273 274 275 276 277 const matches = (278 includeAll279 ? vault280 .getEntries()281 .filter((entry) => entry.type !== "note" && entry.type !== "card")282 : vault.queryDomain(domain)283 ).filter((entry) => !isPasskeyOnly(entry));284 return {285 ok: true,286 data: matches.map((e) =>287 includeAll288 ? {289 id: e.id,290 title: e.title,291 hasPasskey: e.passkey !== undefined,292 }293 : {294 id: e.id,295 title: e.title,296 username: e.username,297 url: e.url,298 hasTotp: Boolean(e.totp),299 hasPasskey: e.passkey !== undefined,300 }301 ),302 };303 }304 case "GET_ENTRY_FOR_FILL": {305 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };306 const { id } = msg.payload as { id: string };307 const entry = vault.getEntryById(id);308 if (!entry) return { ok: false, error: "entry not found" };309 310 311 312 if (isPasskeyOnly(entry)) return { ok: false, error: "passkey not fillable" };313 314 315 316 const { passkey: _strippedPasskey, ...safeEntry } = entry;317 return { ok: true, data: safeEntry };318 }319 case "COPY": {320 const { text, label, alreadyWritten } = msg.payload as {321 text: string;322 label: string;323 alreadyWritten?: boolean;324 };325 await copyToClipboard(text, label, Boolean(alreadyWritten));326 return { ok: true };327 }328 case "CHECK_BREACH": {329 const { password } = msg.payload as { password: string };330 const count = await vault.checkBreach(password);331 return { ok: true, data: count };332 }333 case "CHECK_ALL_BREACHES": {334 const allEntries = vault.getEntries();335 const results: Record<string, number> = {};336 for (const entry of allEntries) {337 if (entry.type === "note") continue;338 if (!entry.password) continue;339 results[entry.id] = await vault.checkBreach(entry.password);340 341 await new Promise((r) => setTimeout(r, 200));342 }343 344 for (const [id, count] of Object.entries(results)) {345 const entry = vault.getEntryById(id);346 if (entry && (entry.breach ?? 0) !== count) {347 await vault.updateEntry(id, { breach: count > 0 ? count : undefined });348 }349 }350 return { ok: true, data: results };351 }352 case "GET_SETTINGS":353 return { ok: true, data: await vault.ensureSettingsLoaded() };354 case "UPDATE_SETTINGS": {355 const patch = msg.payload as Record<string, unknown>;356 await vault.updateSettings(patch as any);357 if (typeof patch.autoLockMinutes === "number") {358 await setupAutoLock();359 if (vault.isUnlocked()) touchAutoLock();360 }361 return { ok: true };362 }363 case "GET_PENDING_PASSKEY":364 return { ok: true, data: await getPendingPasskeyRequest() };365 case "APPROVE_PASSKEY": {366 const { requestId } = msg.payload as { requestId: number };367 await resolvePendingPasskeyRequest(requestId, true);368 return { ok: true };369 }370 case "DENY_PASSKEY": {371 const { requestId } = msg.payload as { requestId: number };372 await resolvePendingPasskeyRequest(requestId, false);373 return { ok: true };374 }375 case "GET_PASSKEYS":376 return {377 ok: true,378 data: vault.getEntries().filter((entry) => entry.passkey !== undefined),379 };380 case "DELETE_PASSKEY": {381 const { id } = msg.payload as { id: string };382 await vault.deleteEntry(id);383 return { ok: true };384 }385 case "GET_PASSKEY_PROVIDER_STATE":386 return { ok: true, data: await getPasskeyProviderState() };387 case "SET_PASSKEY_PROVIDER_ENABLED": {388 const { enabled } = msg.payload as { enabled: boolean };389 390 391 if (enabled) {392 const gate = await requirePro("passkeys");393 if (!gate.allowed) return { ok: false, error: "pro_required" };394 }395 return { ok: true, data: await setPasskeyProviderEnabled(enabled) };396 }397 case "FIRE_CANARY_PING": {398 const { webhookUrl, entryTitle, trigger } = msg.payload as {399 webhookUrl: string;400 entryTitle: string;401 trigger: "password_copied" | "username_copied" | "autofilled" | "submitted" | "test";402 };403 if (!isAllowedCanaryWebhookUrl(webhookUrl)) {404 return { ok: false, error: "invalid webhook url" };405 }406 const body = JSON.stringify({407 event: trigger === "test" ? "peach_canary_test" : "peach_canary_accessed",408 entry: entryTitle,409 trigger,410 timestamp: new Date().toISOString(),411 source: "peach-extension",412 });413 if (trigger === "test") {414 415 416 417 418 419 try {420 const res = await fetch(webhookUrl, {421 method: "POST",422 headers: { "Content-Type": "application/json" },423 body,424 });425 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };426 } catch (err) {427 return { ok: false, error: err instanceof Error ? err.message : "network error" };428 }429 }430 431 fetch(webhookUrl, {432 method: "POST",433 headers: { "Content-Type": "application/json" },434 body,435 }).catch(() => {});436 return { ok: true };437 }438 case "ACTIVATE_LICENSE": {439 const { key } = msg.payload as { key: string };440 const licenseInfo = await activateLicense(key);441 return { ok: true, data: licenseInfo };442 }443 case "GET_LICENSE": {444 const licenseInfo = await getLicenseInfo();445 return { ok: true, data: licenseInfo };446 }447 case "GET_LICENSE_KEY": {448 const key = await getLicenseKey();449 return { ok: true, data: key };450 }451 case "DEACTIVATE_LICENSE": {452 await deactivateLicense();453 return { ok: true };454 }455 case "SHOULD_PLAY_CELEBRATION": {456 const should = await shouldPlayCelebration();457 return { ok: true, data: should };458 }459 case "MARK_CELEBRATION_PLAYED": {460 await markCelebrationPlayed();461 return { ok: true };462 }463 case "OPEN_PRO_GATE": {464 465 const { feature } = (msg.payload ?? {}) as { feature?: string };466 if (chrome.storage.session) {467 await chrome.storage.session.set({ pendingProGate: { feature: feature ?? "passkeys" } });468 }469 try { await chrome.action.openPopup(); } catch { }470 return { ok: true };471 }472 case "GET_IDENTITIES": {473 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };474 return { ok: true, data: vault.getIdentities() };475 }476 case "GET_ADVANCED_HEALTH_REPORT": {477 const gate = await requirePro("advanced_health");478 if (!gate.allowed) return { ok: false, error: "pro_required" };479 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };480 const report = await buildAdvancedHealthReport(vault.getEntries());481 return { ok: true, data: report };482 }483 case "INITIATE_PASSWORD_CHANGE": {484 const gate = await requirePro("smart_password_change");485 if (!gate.allowed) return { ok: false, error: "pro_required" };486 if (!vault.isUnlocked()) return { ok: false, error: "vault locked" };487 const { id } = msg.payload as { id: string };488 const entry = vault.getEntryById(id);489 if (!entry) return { ok: false, error: "entry not found" };490 const url = resolveResetUrl(entry);491 if (!url) return { ok: false, error: "no reset url" };492 try {493 await chrome.tabs.create({ url });494 return { ok: true, data: { url } };495 } catch (err) {496 return {497 ok: false,498 error: err instanceof Error ? err.message : "failed to open tab",499 };500 }501 }502 case "IS_DURESS_ENABLED": {503 const enabled = await vault.isDuressEnabled();504 return { ok: true, data: enabled };505 }506 case "SETUP_DURESS": {507 const { duressPassword } = msg.payload as { duressPassword: string };508 await vault.setupDuress(duressPassword);509 return { ok: true };510 }511 case "DISABLE_DURESS": {512 await vault.disableDuress();513 return { ok: true };514 }515 case "VERIFY_MAIN_PASSWORD": {516 const { password } = msg.payload as { password: string };517 const ok = await vault.verifyMainPassword(password);518 return { ok: true, data: ok };519 }520 case "CHANGE_PASSWORD": {521 const { currentPassword, newPassword } = msg.payload as {522 currentPassword: string;523 newPassword: string;524 };525 const changed = await vault.changePassword(currentPassword, newPassword);526 return changed527 ? { ok: true }528 : { ok: false, error: "incorrect current password" };529 }530 case "GET_SITE_PASSWORD_POLICY": {531 const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });532 if (!tab?.id) return { ok: true, data: null };533 try {534 const response = await chrome.tabs.sendMessage(tab.id, {535 type: "GET_SITE_PASSWORD_POLICY",536 });537 return (response as MessageResponse) ?? { ok: true, data: null };538 } catch {539 return { ok: true, data: null };540 }541 }542 case "GET_SYNC_STATE": {543 const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');544 const { isOnTailscale } = await import('./sync-network');545 const { isProUser } = await import('./license');546 const pairedDevices = await getPairedDevices();547 const trustedNetworks = await getTrustedNetworks();548 const tailscale = await isOnTailscale();549 const pro = await isProUser();550 551 let status: SyncStatus = "offline";552 if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {553 if (pairedDevices.length === 0) {554 status = "no_peers";555 } else {556 status = "synced";557 }558 } else {559 status = "not_trusted";560 }561 562 return { ok: true, data: { pairedDevices, trustedNetworks, status, isPro: pro } };563 }564 case "TRIGGER_SYNC": {565 const gate = await requirePro("sync");566 if (!gate.allowed) return { ok: false, error: "pro_required" };567 return { ok: true };568 }569 case "START_PAIRING": {570 const gate = await requirePro("sync");571 if (!gate.allowed) return { ok: false, error: "pro_required" };572 return { ok: true };573 }574 case "CANCEL_PAIRING": {575 return { ok: true };576 }577 case "ACCEPT_PAIRING": {578 const gate = await requirePro("sync");579 if (!gate.allowed) return { ok: false, error: "pro_required" };580 return { ok: true };581 }582 case "REMOVE_PAIRED_DEVICE": {583 const { deviceId } = msg.payload as { deviceId: string };584 const { removePairedDevice } = await import('./sync-storage');585 await removePairedDevice(deviceId);586 return { ok: true };587 }588 case "REMOVE_TRUSTED_NETWORK": {589 const { ssid, bssid, isTailscale } = msg.payload as any;590 const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');591 const networks = await getTrustedNetworks();592 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 593 (isTailscale ? n.isTailscale : n.ssid === ssid) && 594 (!bssid || n.bssid === bssid)595 );596 if (network) {597 await removeTrustedNetwork(network.addedAt);598 }599 return { ok: true };600 }601 case "MARK_NETWORK_TRUSTED": {602 const { addTrustedNetwork } = await import('./sync-storage');603 const { getCurrentNetwork } = await import('./sync-network');604 const network = await getCurrentNetwork();605 await addTrustedNetwork({606 ssid: network.ssid,607 bssid: network.bssid,608 isTailscale: network.isTailscale,609 addedAt: Date.now(),610 });611 return { ok: true };612 }613 case "RECORD_MANUAL_SUMMON": {614 const { domain } = msg.payload as { domain: string };615 const settings = vault.getSettings();616 const existing = settings.manualSummonDomains?.[domain];617 const now = Date.now();618 await vault.updateSettings({619 manualSummonDomains: {620 ...settings.manualSummonDomains,621 [domain]: {622 firstSummonedAt: existing?.firstSummonedAt ?? now,623 summonCount: (existing?.summonCount ?? 0) + 1,624 lastSummonedAt: now,625 },626 },627 });628 return { ok: true };629 }630 case "DECLINE_CARD_SAVE": {631 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };632 await vault.declineCardSaveOffer(last4, expMonth, expYear);633 return { ok: true };634 }635 case "IS_CARD_DECLINED": {636 const { last4, expMonth, expYear } = msg.payload as { last4: string; expMonth: string; expYear: string };637 const declined = await vault.isCardDeclined(last4, expMonth, expYear);638 return { ok: true, data: declined };639 }640 case "INITIATE_FIREFOX_PASSKEY_CREATE": {641 const { rpId, rpName, userName, userDisplayName } = (msg.payload ?? {}) as {642 rpId?: string;643 rpName?: string;644 userName?: string;645 userDisplayName?: string;646 };647 if (!rpId || !userName) return { ok: false, error: "rpId and userName required" };648 return await initiateFirefoxPasskeyCreate({649 rpId,650 rpName: rpName ?? rpId,651 userName,652 userDisplayName,653 });654 }655656 case "COMPLETE_FIREFOX_PASSKEY_CREATE": {657 const payload = msg.payload as Parameters<typeof completeFirefoxPasskeyCreate>[0] | undefined;658 if (!payload) return { ok: false, error: "missing payload" };659 return await completeFirefoxPasskeyCreate(payload);660 }661662 case "PASSKEY_OFFER_DECLINE": {663 const { domain } = (msg.payload ?? {}) as { domain?: string };664 if (!domain) return { ok: false, error: "domain required" };665 await recordPasskeyOfferDecline(domain);666 return { ok: true };667 }668669 case "GET_PASSKEY_OFFER_DECLINED": {670 const { domain } = (msg.payload ?? {}) as { domain?: string };671 if (!domain) return { ok: false, error: "domain required" };672 return { ok: true, data: await isPasskeyOfferDeclined(domain) };673 }674675 case "PASSKEY_HAS_CREDENTIALS": {676 const payload = msg.payload as {677 rpId?: string;678 allowCredentials?: Array<{ id: string; type?: string }>;679 } | undefined;680 if (!payload?.rpId) return { ok: false, error: "rpId required" };681 return await handlePasskeyHasCredentials({682 rpId: payload.rpId,683 allowCredentials: payload.allowCredentials ?? [],684 });685 }686687 case "PASSKEY_CREATE": {688 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyCreate>[0] | undefined;689 if (!payload?.rpId || !payload?.userName) {690 return { ok: false, error: "rpId and userName required" };691 }692 return await handleFirefoxPasskeyCreate(payload);693 }694695 case "PASSKEY_GET": {696 const payload = msg.payload as Parameters<typeof handleFirefoxPasskeyGet>[0] | undefined;697 if (!payload?.rpId) return { ok: false, error: "rpId required" };698 return await handleFirefoxPasskeyGet(payload);699 }700701 case "GET_PASSKEY_FOR_DOMAIN": {702 const { domain } = (msg.payload ?? {}) as { domain?: string };703 if (!domain) return { ok: false, error: "domain required" };704 if (!vault.isUnlocked()) return { ok: true, data: null };705 const match = vault706 .getEntries()707 .find((entry) => entry.passkey?.rpId === domain);708 if (!match || !match.passkey) return { ok: true, data: null };709 710 711 return {712 ok: true,713 data: {714 rpId: match.passkey.rpId,715 userName: match.passkey.userName,716 browser: match.passkey.browser,717 },718 };719 }720721 case "DESTROY_VAULT": {722 723 724 725 726 727 vault.lock();728 await chrome.storage.local.clear();729 if (chrome.storage.session) {730 await chrome.storage.session.clear();731 }732 return { ok: true };733 }734735 case "SUBMIT_FEEDBACK": {736 const { category, message, version, browser } = msg.payload as {737 category: string;738 message: string;739 version: string;740 browser: string;741 };742 if (!message.trim()) return { ok: false, error: "message is empty" };743 if (!FEEDBACK_ENDPOINT) return { ok: true };744 if (!isAllowedCanaryWebhookUrl(FEEDBACK_ENDPOINT)) {745 return { ok: false, error: "invalid feedback endpoint" };746 }747 try {748 const res = await fetch(FEEDBACK_ENDPOINT, {749 method: "POST",750 headers: { "Content-Type": "application/json" },751 body: JSON.stringify({ category, message, version, browser, source: "peach-extension" }),752 });753 return { ok: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };754 } catch (err) {755 return { ok: false, error: err instanceof Error ? err.message : "network error" };756 }757 }758 default:759 return { ok: false, error: "unknown message type" };760 }761 } catch (err) {762 return {763 ok: false,764 error: err instanceof Error ? err.message : "operation failed",765 };766 }767}768769770function broadcastToContentScripts(message: { type: string }): void {771 chrome.tabs.query({}, (tabs) => {772 for (const tab of tabs) {773 if (tab.id !== undefined) {774 chrome.tabs.sendMessage(tab.id, message).catch(() => {775 776 });777 }778 }779 });780}781782function shouldRefreshAutoLock(type: MessageType): boolean {783 return (784 type !== "HAS_VAULT" &&785 type !== "IS_UNLOCKED" &&786 type !== "GET_VAULT_STATUS" &&787 type !== "LOCK" &&788 type !== "UPDATE_SETTINGS" &&789 type !== "GET_PENDING_PASSKEY" &&790 type !== "GET_PASSKEY_PROVIDER_STATE" &&791 type !== "DECLINE_CARD_SAVE" &&792 type !== "IS_CARD_DECLINED" &&793 type !== "SUBMIT_FEEDBACK"794 );795}796797function isAllowedCanaryWebhookUrl(value: string): boolean {798 try {799 const url = new URL(value);800 if (url.protocol !== "https:") return false;801802 const hostname = url.hostname.toLowerCase();803 if (804 hostname === "localhost" ||805 hostname.endsWith(".localhost") ||806 isPrivateIpv4(hostname) ||807 isPrivateIpv6(hostname)808 ) {809 return false;810 }811812 return true;813 } catch {814 return false;815 }816}817818function isPrivateIpv4(hostname: string): boolean {819 if (PRIVATE_IPV4_RANGES.some((range) => hostname.startsWith(range.prefix))) {820 return true;821 }822823 const octets = hostname.split(".").map((part) => Number(part));824 if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {825 return false;826 }827828 return octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31;829}830831function isPrivateIpv6(hostname: string): boolean {832 const normalized = hostname.replace(/^\[|\]$/g, "").toLowerCase();833 return (834 normalized === "::1" ||835 normalized.startsWith("fc") ||836 normalized.startsWith("fd") ||837 normalized.startsWith("fe80:")838 );839}