Add paste validation for invites and pearcord:// deep links (P410-30).

Centralize validatePastedNavigationInput in pearcord-shared so UI, platform,
and protocol can reject dangerous or malformed navigation input before dispatch.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Raven Scott
2026-06-02 14:47:05 -04:00
co-authored by Cursor
parent 5985dbf091
commit 776f1d98d4
3 changed files with 152 additions and 0 deletions
+2
View File
@@ -6,6 +6,8 @@ Cross-cutting constants, topic naming, RPC framing, permission helpers, message
**Phase 659 (v0.8.638):** Added DM scheduled-message helpers — `parseDmScheduleCreateCommand` (`/schedule`, `/sendlater`), `parseDmScheduleDelaySpec`, and `normalizeDmScheduledMessagePayload` schema validation (`sendAt`, `timezone`, `recurrence`) with queue delay bounds.
**Phase 410 (v0.8.638):** Added paste/navigation validation — `validatePastedNavigationInput`, `pasteValidationToast`, `decodePortableInvitePayload`, and `PASTE_NAV_MAX_LEN` for invite/deep-link gates before IPC and `navigateDeepLink`.
## Mission
Provide a single source of truth for HyperDB collection names, channel and member enums, gossip RPC method IDs, Hyperswarm topic strings, and local permission bitmask checks. Avoid duplicating wire formats or ID generation across guild, DM, platform, and bot packages.
+10
View File
@@ -640,6 +640,12 @@ const {
formatExploreBaselineDeepLink,
formatSettingsDeepLink
} = require('./deep-links')
const {
PASTE_NAV_MAX_LEN,
validatePastedNavigationInput,
pasteValidationToast,
decodePortableInvitePayload
} = require('./invite-deep-link-validation')
const { isBenignSwarmError, wireSwarmConnection } = require('./swarm-conn')
module.exports = {
isBenignSwarmError,
@@ -705,6 +711,10 @@ module.exports = {
formatDevicePairDeepLink,
formatExploreBaselineDeepLink,
formatSettingsDeepLink,
PASTE_NAV_MAX_LEN,
validatePastedNavigationInput,
pasteValidationToast,
decodePortableInvitePayload,
DM_POLL_MAX_OPTIONS,
DM_POLL_EMOJIS,
DM_POLL_DEFAULT_EXPIRES_MS,
+140
View File
@@ -0,0 +1,140 @@
'use strict'
const {
isPearcordDeepLink,
parsePearcordDeepLink
} = require('./deep-links')
/** Max pasted invite / deep-link length before navigation dispatch. */
const PASTE_NAV_MAX_LEN = 4096
const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/
const BLOCKED_SCHEME = /^(javascript|data|vbscript|file|blob):/i
const HTTP_SCHEME = /^https?:\/\//i
const SHORT_INVITE = /^[a-z0-9]{6,32}$/i
const PORTABLE_PREFIX = /^pcd_[A-Za-z0-9_-]+$/
const GUILD_ID = /^[a-f0-9-]{8,128}$/i
const PAIR_PREFIX = /^pcdv_[A-Za-z0-9_-]+$/
const TOAST = {
empty: 'Paste an invite code or pearcord:// link to join a server.',
too_long: 'That link is too long to open — use a shorter Pearcord invite.',
invalid_chars: 'Remove unusual control characters and try again.',
blocked_scheme: 'Only Pearcord invite codes and pearcord:// links can be opened here.',
http_not_supported:
'Web URLs are not opened here — paste a pearcord:// link or portable pcd_ invite.',
deep_link_whitespace: 'Remove spaces from the pearcord:// link and try again.',
not_pearcord: 'Use a pearcord:// link or a Pearcord invite code.',
unrecognized_deep_link: 'That pearcord:// link is not recognized.',
invalid_invite: 'That invite code is not valid.',
portable_charset: 'Portable invites must look like pcd_ followed by letters and numbers.',
portable_payload: 'That portable invite could not be decoded.',
pair_invalid: 'That device pairing code is not valid.',
multiple_links: 'Paste one invite or link at a time.'
}
function base64UrlToUtf8 (b64url) {
let s = String(b64url || '').replace(/-/g, '+').replace(/_/g, '/')
while (s.length % 4) s += '='
if (typeof Buffer !== 'undefined') {
return Buffer.from(s, 'base64').toString('utf8')
}
const bin = atob(s)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return new TextDecoder().decode(bytes)
}
function decodePortableInvitePayload (input) {
const str = String(input || '').trim()
if (!str.toLowerCase().startsWith('pcd_')) return null
if (!PORTABLE_PREFIX.test(str) || str.length > PASTE_NAV_MAX_LEN) return null
try {
const payload = JSON.parse(base64UrlToUtf8(str.slice(4)))
if (!payload || typeof payload !== 'object') return null
if (payload.v !== 2 && payload.v !== 1) return null
if (!payload.guildId || !payload.code) return null
if (!GUILD_ID.test(String(payload.guildId))) return null
if (!SHORT_INVITE.test(String(payload.code)) && !PORTABLE_PREFIX.test(String(payload.code))) {
return null
}
return payload
} catch {
return null
}
}
function validateInviteSubcode (code) {
const s = String(code || '').trim()
if (!s) return { ok: false, code: 'invalid_invite' }
if (s.length > PASTE_NAV_MAX_LEN) return { ok: false, code: 'invalid_invite' }
if (CONTROL_CHARS.test(s) || /\s/.test(s)) return { ok: false, code: 'invalid_invite' }
const lower = s.toLowerCase()
if (lower.startsWith('pearcord://')) {
return validatePastedNavigationInput(s)
}
if (lower.startsWith('pcd_')) {
if (!PORTABLE_PREFIX.test(s)) return { ok: false, code: 'portable_charset' }
if (!decodePortableInvitePayload(s)) return { ok: false, code: 'portable_payload' }
return { ok: true, kind: 'invite', normalized: s }
}
if (lower.startsWith('pcdv_')) {
if (!PAIR_PREFIX.test(s) || s.length > 512) return { ok: false, code: 'pair_invalid' }
return { ok: true, kind: 'pair', normalized: s }
}
if (!SHORT_INVITE.test(s)) return { ok: false, code: 'invalid_invite' }
return { ok: true, kind: 'invite', normalized: s }
}
/**
* Validate pasted invite codes and pearcord:// deep links before IPC / navigation.
* @returns {{ ok: true, kind: string, normalized: string, parsed?: object } | { ok: false, code: string }}
*/
function validatePastedNavigationInput (raw) {
const s = String(raw || '').trim()
if (!s) return { ok: false, code: 'empty' }
if (s.length > PASTE_NAV_MAX_LEN) return { ok: false, code: 'too_long' }
if (CONTROL_CHARS.test(s)) return { ok: false, code: 'invalid_chars' }
if (/\r|\n/.test(s)) return { ok: false, code: 'invalid_chars' }
const lower = s.toLowerCase()
if (BLOCKED_SCHEME.test(lower)) return { ok: false, code: 'blocked_scheme' }
if (HTTP_SCHEME.test(lower)) return { ok: false, code: 'http_not_supported' }
const pearcordHits = (s.match(/pearcord:\/\//gi) || []).length
if (pearcordHits > 1) return { ok: false, code: 'multiple_links' }
if (lower.startsWith('pearcord://')) {
if (/\s/.test(s)) return { ok: false, code: 'deep_link_whitespace' }
if (!isPearcordDeepLink(s)) return { ok: false, code: 'not_pearcord' }
const parsed = parsePearcordDeepLink(s)
if (!parsed) return { ok: false, code: 'unrecognized_deep_link' }
if (parsed.kind === 'invite' && parsed.inviteCode) {
const nested = validateInviteSubcode(parsed.inviteCode)
if (!nested.ok) return nested
}
if (parsed.kind === 'pair' && parsed.pairCode) {
const nested = validateInviteSubcode(parsed.pairCode)
if (!nested.ok) return nested
}
if (parsed.guildId && !GUILD_ID.test(String(parsed.guildId))) {
return { ok: false, code: 'unrecognized_deep_link' }
}
return { ok: true, kind: parsed.kind, normalized: s, parsed }
}
return validateInviteSubcode(s)
}
function pasteValidationToast (result) {
if (!result || result.ok) return ''
return TOAST[result.code] || TOAST.invalid_invite
}
module.exports = {
PASTE_NAV_MAX_LEN,
TOAST,
validatePastedNavigationInput,
pasteValidationToast,
decodePortableInvitePayload
}