Restrict public swarm participation and propagation to real public ICANN domains

Hop-State: A_06FP8GM0PKJTMF45B7016J0
Hop-Proposal: R_06FP8GKFXXZG84ETVCPS5ZG
Hop-Task: T_06FP8F45T3G4Z4ESW9NZQQR
Hop-Attempt: AT_06FP8F45T3QS81M2ZM4SJ3G
This commit is contained in:
2026-07-14 22:31:47 -07:00
committed by Hop
parent 988bd8ab38
commit 1427df4bdc
13 changed files with 282 additions and 61 deletions
+38 -8
View File
@@ -8,6 +8,9 @@ import { db, nodes, users, posts } from '@/db';
import { eq, sql } from 'drizzle-orm';
import type { SwarmAnnouncement, SwarmNodeInfo, SwarmCapability } from './types';
import { upsertSwarmNode, getSeedNodes, markNodeSuccess, markNodeFailure } from './registry';
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
const PUBLIC_SWARM_DOMAIN_ERROR = 'Public swarm participation requires a real ICANN domain';
function normalizeOptionalUrl(value: string | null | undefined): string | undefined {
if (!value) {
@@ -77,6 +80,15 @@ export async function buildAnnouncement(): Promise<SwarmAnnouncement> {
* SECURITY: Signs the announcement with the node's private key
*/
export async function announceToNode(targetDomain: string): Promise<{ success: boolean; error?: string }> {
if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN)) {
return { success: false, error: PUBLIC_SWARM_DOMAIN_ERROR };
}
const publicTargetDomain = getPublicSwarmDomain(targetDomain);
if (!publicTargetDomain) {
return { success: false, error: `Invalid public swarm domain: ${targetDomain}` };
}
try {
const announcement = await buildAnnouncement();
@@ -90,7 +102,7 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b
signature,
};
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
const baseUrl = `https://${publicTargetDomain}`;
const url = `${baseUrl}/api/swarm/announce`;
const response = await fetch(url, {
@@ -104,20 +116,23 @@ export async function announceToNode(targetDomain: string): Promise<{ success: b
if (!response.ok) {
const error = await response.text();
await markNodeFailure(targetDomain);
await markNodeFailure(publicTargetDomain);
return { success: false, error: `HTTP ${response.status}: ${error}` };
}
// The remote node should respond with their info
const remoteInfo = await response.json() as SwarmNodeInfo;
if (getPublicSwarmDomain(remoteInfo.domain) !== publicTargetDomain) {
return { success: false, error: 'Remote node returned a different domain identity' };
}
// Add/update the remote node in our registry
await upsertSwarmNode(remoteInfo, 'direct');
await markNodeSuccess(targetDomain);
await markNodeSuccess(publicTargetDomain);
return { success: true };
} catch (error) {
await markNodeFailure(targetDomain);
await markNodeFailure(publicTargetDomain);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
@@ -134,6 +149,10 @@ export async function announceToSeeds(): Promise<{
}> {
const seeds = await getSeedNodes();
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
if (!isPublicSwarmDomain(ourDomain)) {
return { successful: [], failed: [] };
}
// Don't announce to ourselves
const targetSeeds = seeds.filter(s => s !== ourDomain);
@@ -157,8 +176,11 @@ export async function announceToSeeds(): Promise<{
* Fetch node info from a remote node
*/
export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | null> {
const publicDomain = getPublicSwarmDomain(domain);
if (!publicDomain) return null;
try {
const baseUrl = domain.startsWith('http') ? domain : `https://${domain}`;
const baseUrl = `https://${publicDomain}`;
// Try the swarm endpoint first
let response = await fetch(`${baseUrl}/api/swarm/info`, {
@@ -178,8 +200,11 @@ export async function fetchNodeInfo(domain: string): Promise<SwarmNodeInfo | nul
const data = await response.json();
const returnedDomain = getPublicSwarmDomain(data.domain || publicDomain);
if (returnedDomain !== publicDomain) return null;
return {
domain: data.domain || domain,
domain: returnedDomain,
name: data.name,
description: data.description,
logoUrl: data.logoUrl,
@@ -203,13 +228,18 @@ export async function discoverNode(
discoveredVia?: string
): Promise<{ success: boolean; isNew: boolean; error?: string }> {
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
const publicDomain = getPublicSwarmDomain(domain);
if (!publicDomain) {
return { success: false, isNew: false, error: `Invalid public swarm domain: ${domain}` };
}
// Don't discover ourselves
if (domain === ourDomain) {
if (publicDomain === getPublicSwarmDomain(ourDomain)) {
return { success: false, isNew: false, error: 'Cannot discover self' };
}
const info = await fetchNodeInfo(domain);
const info = await fetchNodeInfo(publicDomain);
if (!info) {
return { success: false, isNew: false, error: 'Could not fetch node info' };
+34 -14
View File
@@ -20,6 +20,7 @@ import {
} from './registry';
import { upsertHandleEntries } from '@/lib/federation/handles';
import { buildAnnouncement } from './discovery';
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
/**
* Build a gossip payload to send to another node
@@ -71,8 +72,8 @@ export async function buildGossipPayload(since?: string): Promise<SwarmGossipPay
return {
sender: ourDomain,
nodes: [selfNode, ...nodes],
handles,
nodes: isPublicSwarmDomain(ourDomain) ? [selfNode, ...nodes] : nodes,
handles: handles.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)),
timestamp: new Date().toISOString(),
since,
};
@@ -91,8 +92,9 @@ export async function processGossip(
// Process incoming handles
let handlesResult = { added: 0, updated: 0 };
if (payload.handles && payload.handles.length > 0) {
handlesResult = await upsertHandleEntries(payload.handles);
const publicHandles = payload.handles?.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)) ?? [];
if (publicHandles.length > 0) {
handlesResult = await upsertHandleEntries(publicHandles);
}
// Build our response with nodes/handles to share back
@@ -118,6 +120,19 @@ export async function gossipToNode(
since?: string
): Promise<SwarmSyncResult> {
const startTime = Date.now();
const publicTargetDomain = getPublicSwarmDomain(targetDomain);
if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN) || !publicTargetDomain) {
return {
success: false,
nodesReceived: 0,
nodesSent: 0,
handlesReceived: 0,
handlesSent: 0,
error: 'Public swarm participation requires real ICANN domains',
durationMs: Date.now() - startTime,
};
}
try {
const payload = await buildGossipPayload(since);
@@ -132,7 +147,7 @@ export async function gossipToNode(
signature,
};
const baseUrl = targetDomain.startsWith('http') ? targetDomain : `https://${targetDomain}`;
const baseUrl = `https://${publicTargetDomain}`;
const url = `${baseUrl}/api/swarm/gossip`;
const response = await fetch(url, {
@@ -148,8 +163,8 @@ export async function gossipToNode(
if (!response.ok) {
const error = `HTTP ${response.status}`;
await markNodeFailure(targetDomain);
await logSync(targetDomain, 'push', {
await markNodeFailure(publicTargetDomain);
await logSync(publicTargetDomain, 'push', {
success: false,
nodesReceived: 0,
nodesSent: payload.nodes.length,
@@ -172,14 +187,15 @@ export async function gossipToNode(
const gossipResponse = await response.json() as SwarmGossipResponse;
// Process the response (nodes and handles they sent back)
const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, targetDomain);
const nodeResult = await upsertSwarmNodes(gossipResponse.nodes, publicTargetDomain);
let handlesResult = { added: 0, updated: 0 };
if (gossipResponse.handles && gossipResponse.handles.length > 0) {
handlesResult = await upsertHandleEntries(gossipResponse.handles);
const publicHandles = gossipResponse.handles?.filter((handle) => isPublicSwarmDomain(handle.nodeDomain)) ?? [];
if (publicHandles.length > 0) {
handlesResult = await upsertHandleEntries(publicHandles);
}
await markNodeSuccess(targetDomain);
await markNodeSuccess(publicTargetDomain);
const result: SwarmSyncResult = {
success: true,
@@ -190,13 +206,13 @@ export async function gossipToNode(
durationMs,
};
await logSync(targetDomain, 'push', result);
await logSync(publicTargetDomain, 'push', result);
return result;
} catch (error) {
const durationMs = Date.now() - startTime;
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
await markNodeFailure(targetDomain);
await markNodeFailure(publicTargetDomain);
const result: SwarmSyncResult = {
success: false,
@@ -208,7 +224,7 @@ export async function gossipToNode(
durationMs,
};
await logSync(targetDomain, 'push', result);
await logSync(publicTargetDomain, 'push', result);
return result;
}
}
@@ -222,6 +238,10 @@ export async function runGossipRound(): Promise<{
totalNodesReceived: number;
totalHandlesReceived: number;
}> {
if (!isPublicSwarmDomain(process.env.NEXT_PUBLIC_NODE_DOMAIN)) {
return { contacted: 0, successful: 0, totalNodesReceived: 0, totalHandlesReceived: 0 };
}
// Get random nodes to gossip with
const targets = await getNodesForGossip(SWARM_CONFIG.gossipFanout);
+2 -8
View File
@@ -1,14 +1,8 @@
import { db, swarmNodes } from '@/db';
import { and, eq, inArray } from 'drizzle-orm';
import { normalizeNodeDomain } from './node-domain';
export function normalizeNodeDomain(domain: string): string {
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
.replace(/^@/, '');
}
export { normalizeNodeDomain } from './node-domain';
export async function isNodeBlocked(domain: string | null | undefined): Promise<boolean> {
if (!db || !domain) return false;
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
describe('public swarm domains', () => {
it.each([
['synapsis.social', 'synapsis.social'],
['https://Node.Synapsis.Social/', 'node.synapsis.social'],
['node.synapsis.social:8443', 'node.synapsis.social:8443'],
['social.example.co.uk', 'social.example.co.uk'],
])('accepts a public ICANN domain: %s', (input, expected) => {
expect(getPublicSwarmDomain(input)).toBe(expected);
});
it.each([
'localhost',
'localhost:43821',
'127.0.0.1',
'192.168.1.20:43821',
'[::1]:43821',
'synapsis.local',
'synapsis.test',
'synapsis.invalid',
'synapsis.internal',
'example.com',
'node.example.com',
'node.onion',
'home.arpa',
'not a domain',
])('rejects a local, reserved, or non-public domain: %s', (input) => {
expect(isPublicSwarmDomain(input)).toBe(false);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { parse } from 'tldts';
export function normalizeNodeDomain(domain: string): string {
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//, '')
.replace(/\/.*$/, '')
.replace(/^@/, '');
}
/**
* Return the canonical host for a publicly addressable swarm node.
*
* The swarm is a public network, so local development hosts, IP addresses,
* special-use names, and made-up TLDs must never enter the node registry.
* Ports are retained because a public hostname may intentionally expose
* Synapsis on a non-standard port.
*/
export function getPublicSwarmDomain(value: string | null | undefined): string | null {
if (!value) return null;
const normalized = normalizeNodeDomain(value);
if (!normalized) return null;
try {
const url = new URL(`https://${normalized}`);
if (url.username || url.password) return null;
const hostname = url.hostname.toLowerCase().replace(/\.$/, '');
const result = parse(hostname, {
allowPrivateDomains: false,
detectSpecialUse: true,
});
if (
!result.domain ||
result.isIcann !== true ||
result.isIp ||
result.isSpecialUse
) {
return null;
}
return url.port ? `${hostname}:${url.port}` : hostname;
} catch {
return null;
}
}
export function isPublicSwarmDomain(value: string | null | undefined): boolean {
return getPublicSwarmDomain(value) !== null;
}
+28 -17
View File
@@ -8,7 +8,7 @@ import { db, swarmNodes, swarmSeeds, swarmSyncLog } from '@/db';
import { eq, desc, and, gt, lt, sql } from 'drizzle-orm';
import type { SwarmNodeInfo, SwarmCapability, SwarmSyncResult } from './types';
import { SWARM_CONFIG, DEFAULT_SEED_NODES } from './types';
import { normalizeNodeDomain } from './node-blocklist';
import { getPublicSwarmDomain, isPublicSwarmDomain } from './node-domain';
/**
* Get or create a swarm node entry
@@ -21,11 +21,14 @@ export async function upsertSwarmNode(
return { isNew: false };
}
const existing = await db.query.swarmNodes.findFirst({
where: { domain: normalizeNodeDomain(node.domain) },
});
const normalizedDomain = getPublicSwarmDomain(node.domain);
if (!normalizedDomain) {
throw new Error(`Swarm nodes must use a public ICANN domain: ${node.domain}`);
}
const normalizedDomain = normalizeNodeDomain(node.domain);
const existing = await db.query.swarmNodes.findFirst({
where: { domain: normalizedDomain },
});
const capabilities = node.capabilities ? JSON.stringify(node.capabilities) : null;
@@ -86,8 +89,10 @@ export async function upsertSwarmNodes(
// Filter out our own domain
const ourDomain = process.env.NEXT_PUBLIC_NODE_DOMAIN;
const filteredNodes = nodes.filter(n => n.domain !== ourDomain);
const normalizedOurDomain = ourDomain ? normalizeNodeDomain(ourDomain) : null;
const safeNodes = filteredNodes.filter(n => normalizeNodeDomain(n.domain) !== normalizedOurDomain);
const normalizedOurDomain = getPublicSwarmDomain(ourDomain);
const safeNodes = filteredNodes.filter(n =>
isPublicSwarmDomain(n.domain) && getPublicSwarmDomain(n.domain) !== normalizedOurDomain
);
for (const node of safeNodes) {
const result = await upsertSwarmNode(node, discoveredVia);
@@ -115,7 +120,7 @@ export async function getActiveSwarmNodes(limit = 100): Promise<SwarmNodeInfo[]>
limit,
});
return nodes.map(nodeToInfo);
return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo);
}
/**
@@ -133,7 +138,7 @@ export async function getNodesForGossip(count: number): Promise<SwarmNodeInfo[]>
limit: count,
});
return nodes.map(nodeToInfo);
return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo);
}
/**
@@ -150,7 +155,7 @@ export async function getNodesSince(since: Date, limit = 100): Promise<SwarmNode
limit,
});
return nodes.map(nodeToInfo);
return nodes.filter((node) => isPublicSwarmDomain(node.domain)).map(nodeToInfo);
}
/**
@@ -268,11 +273,11 @@ export async function getSeedNodes(): Promise<string[]> {
orderBy: (swarmSeeds) => [swarmSeeds.priority],
});
if (seeds.length === 0) {
return [...DEFAULT_SEED_NODES];
}
const publicSeeds = seeds.map(s => s.domain).filter(isPublicSwarmDomain);
return seeds.map(s => s.domain);
return publicSeeds.length > 0
? publicSeeds
: DEFAULT_SEED_NODES.filter(isPublicSwarmDomain);
}
/**
@@ -281,8 +286,13 @@ export async function getSeedNodes(): Promise<string[]> {
export async function addSeedNode(domain: string, priority = 100): Promise<void> {
if (!db) return;
const normalizedDomain = getPublicSwarmDomain(domain);
if (!normalizedDomain) {
throw new Error(`Seed nodes must use a public ICANN domain: ${domain}`);
}
await db.insert(swarmSeeds)
.values({ domain, priority })
.values({ domain: normalizedDomain, priority })
.onConflictDoUpdate({
target: swarmSeeds.domain,
set: { priority, isEnabled: true },
@@ -303,13 +313,14 @@ export async function getSwarmStats() {
}
const allNodes = await db.query.swarmNodes.findMany();
const activeNodes = allNodes.filter(n => n.isActive);
const publicNodes = allNodes.filter(n => isPublicSwarmDomain(n.domain));
const activeNodes = publicNodes.filter(n => n.isActive);
const totalUsers = activeNodes.reduce((sum, n) => sum + (n.userCount || 0), 0);
const totalPosts = activeNodes.reduce((sum, n) => sum + (n.postCount || 0), 0);
return {
totalNodes: allNodes.length,
totalNodes: publicNodes.length,
activeNodes: activeNodes.length,
totalUsers,
totalPosts,
+19 -8
View File
@@ -10,7 +10,8 @@ import crypto from 'crypto';
import { db, users } from '@/db';
import { eq } from 'drizzle-orm';
import { canonicalize } from '@/lib/crypto/user-signing';
import { isNodeBlocked, normalizeNodeDomain } from './node-blocklist';
import { isNodeBlocked } from './node-blocklist';
import { getPublicSwarmDomain } from './node-domain';
/**
* Sign a payload with the node's private key
@@ -57,15 +58,18 @@ export function verifySignature(payload: any, signature: string, publicKey: stri
*/
export async function getNodePublicKey(domain: string): Promise<string | null> {
try {
const normalizedDomain = normalizeNodeDomain(domain);
const normalizedDomain = getPublicSwarmDomain(domain);
if (!normalizedDomain) {
console.warn(`[Signature] Refusing public key fetch for non-public node ${domain}`);
return null;
}
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Refusing public key fetch for blocked node ${normalizedDomain}`);
return null;
}
// Check if we have a cached node info
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${normalizedDomain}/api/node`, {
const response = await fetch(`https://${normalizedDomain}/api/node`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});
@@ -96,7 +100,11 @@ export async function verifySwarmRequest(
signature: string,
senderDomain: string
): Promise<boolean> {
const normalizedDomain = normalizeNodeDomain(senderDomain);
const normalizedDomain = getPublicSwarmDomain(senderDomain);
if (!normalizedDomain) {
console.warn(`[Signature] Rejected non-public swarm node ${senderDomain}`);
return false;
}
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Rejected blocked node ${normalizedDomain}`);
return false;
@@ -132,7 +140,11 @@ export async function verifyUserInteraction(
userDomain: string
): Promise<boolean> {
try {
const normalizedDomain = normalizeNodeDomain(userDomain);
const normalizedDomain = getPublicSwarmDomain(userDomain);
if (!normalizedDomain) {
console.warn(`[Signature] Rejected user interaction from non-public node ${userDomain}`);
return false;
}
if (await isNodeBlocked(normalizedDomain)) {
console.warn(`[Signature] Rejected user interaction from blocked node ${normalizedDomain}`);
return false;
@@ -150,8 +162,7 @@ export async function verifyUserInteraction(
publicKey = user.publicKey;
} else {
// Fetch from remote node
const protocol = normalizedDomain.includes('localhost') ? 'http' : 'https';
const response = await fetch(`${protocol}://${normalizedDomain}/api/users/${userHandle}`, {
const response = await fetch(`https://${normalizedDomain}/api/users/${userHandle}`, {
headers: { 'Accept': 'application/json' },
signal: AbortSignal.timeout(5000),
});