feat: user-configurable STUN/TURN servers (advanced network settings)
- add iceServers.js: allowlist-based validation/normalization of user-supplied STUN/TURN URLs (rejects javascript:/data:/http/ws, control chars, enforces limits) - add iceSettingsStore.js: opt-in persistence encrypted at rest with a non-extractable AES-GCM device key in IndexedDB; load/save/clear - add IceServerSettings.jsx modal: public vs custom servers, JSON/line input, live validation, relay-only toggle, 'Test servers' connectivity check, save-on-device prompt, forget-saved action - wire chosen servers/privacy mode into EnhancedSecureWebRTCManager construction (priority: custom > operator override > built-in defaults) - entry point on the connection-creation screen next to the relay-only toggle - add ice-servers-validation.test.mjs to the suite
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
// Pure, dependency-free validation and normalization of user-supplied ICE
|
||||
// (STUN/TURN) servers. No DOM, no browser APIs — safe to unit-test in Node.
|
||||
//
|
||||
// Security intent: a user can paste arbitrary text here. We must never hand an
|
||||
// un-vetted value to RTCPeerConnection, and we must reject anything that is not
|
||||
// a real STUN/TURN URL (e.g. javascript:, data:, http(s):, ws(s):) so the field
|
||||
// cannot be abused as an injection surface or silently break the connection.
|
||||
// Validation is allowlist-based: only known-good schemes, host shapes, and the
|
||||
// standard transport query are accepted; everything else is rejected.
|
||||
|
||||
export const ICE_LIMITS = Object.freeze({
|
||||
MAX_SERVERS: 10,
|
||||
MAX_URLS_PER_SERVER: 8,
|
||||
MAX_STRING_LENGTH: 512
|
||||
});
|
||||
|
||||
// RTCIceServer only accepts these schemes. Everything else is rejected.
|
||||
export const ALLOWED_ICE_SCHEMES = Object.freeze(['stun', 'stuns', 'turn', 'turns']);
|
||||
|
||||
const SCHEME_RE = /^(stuns?|turns?):/i;
|
||||
// Positive allowlist for the host portion: hostname/IPv4, or bracketed IPv6,
|
||||
// with an optional numeric port. Anything outside this shape is rejected.
|
||||
const HOST_RE = /^(\[[0-9a-f:]+\]|[a-z0-9.-]+)(:\d{1,5})?$/i;
|
||||
const TRANSPORT_RE = /^transport=(udp|tcp)$/i;
|
||||
|
||||
// True if the string contains any ASCII control character (0x00–0x1F or 0x7F).
|
||||
// Implemented via char codes to keep the source free of literal control bytes.
|
||||
function hasControlChars(value) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code < 0x20 || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single ICE URL string.
|
||||
* @returns {string|null} error message, or null if valid.
|
||||
*/
|
||||
export function validateIceUrl(url) {
|
||||
if (typeof url !== 'string') return 'URL must be a string';
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return 'URL is empty';
|
||||
if (trimmed.length > ICE_LIMITS.MAX_STRING_LENGTH) return 'URL is too long';
|
||||
if (hasControlChars(trimmed)) return 'URL contains invalid characters';
|
||||
|
||||
const scheme = trimmed.match(SCHEME_RE);
|
||||
if (!scheme) {
|
||||
return 'URL must start with stun:, stuns:, turn: or turns:';
|
||||
}
|
||||
|
||||
// Validate the part after "<scheme>:" — host[:port][?transport=udp|tcp].
|
||||
const rest = trimmed.slice(scheme[0].length);
|
||||
const [hostPort, query, ...extra] = rest.split('?');
|
||||
if (extra.length > 0) return 'URL has an invalid query';
|
||||
if (!hostPort) return 'URL is missing a host';
|
||||
if (!HOST_RE.test(hostPort)) return 'URL has an invalid host or port';
|
||||
|
||||
if (query !== undefined && !TRANSPORT_RE.test(query)) {
|
||||
return 'URL query must be transport=udp or transport=tcp';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isTurnUrl(url) {
|
||||
return typeof url === 'string' && /^turns?:/i.test(url.trim());
|
||||
}
|
||||
|
||||
function validateSecret(value, label) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
if (typeof value !== 'string') return `${label} must be a string`;
|
||||
if (value.length > ICE_LIMITS.MAX_STRING_LENGTH) return `${label} is too long`;
|
||||
if (hasControlChars(value)) return `${label} contains invalid characters`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a list of ICE server entries.
|
||||
* Each entry: { urls: string | string[], username?: string, credential?: string }
|
||||
* @returns {{ servers: Array, errors: string[], warnings: string[] }}
|
||||
*/
|
||||
export function normalizeIceServers(entries) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const servers = [];
|
||||
|
||||
if (!Array.isArray(entries)) {
|
||||
return { servers: [], errors: ['Server list must be an array'], warnings: [] };
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return { servers: [], errors: [], warnings: [] };
|
||||
}
|
||||
if (entries.length > ICE_LIMITS.MAX_SERVERS) {
|
||||
errors.push(`Too many servers (max ${ICE_LIMITS.MAX_SERVERS})`);
|
||||
return { servers: [], errors, warnings };
|
||||
}
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const label = `Server #${index + 1}`;
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
errors.push(`${label}: invalid entry`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawUrls = Array.isArray(entry.urls) ? entry.urls : [entry.urls];
|
||||
if (rawUrls.length === 0 || rawUrls.length > ICE_LIMITS.MAX_URLS_PER_SERVER) {
|
||||
errors.push(`${label}: between 1 and ${ICE_LIMITS.MAX_URLS_PER_SERVER} URLs required`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanUrls = [];
|
||||
let entryHasTurn = false;
|
||||
for (const rawUrl of rawUrls) {
|
||||
const err = validateIceUrl(rawUrl);
|
||||
if (err) {
|
||||
errors.push(`${label}: ${err}`);
|
||||
continue;
|
||||
}
|
||||
const trimmed = rawUrl.trim();
|
||||
cleanUrls.push(trimmed);
|
||||
if (isTurnUrl(trimmed)) entryHasTurn = true;
|
||||
}
|
||||
if (cleanUrls.length === 0) return;
|
||||
|
||||
const userErr = validateSecret(entry.username, `${label} username`);
|
||||
if (userErr) errors.push(userErr);
|
||||
const credErr = validateSecret(entry.credential, `${label} credential`);
|
||||
if (credErr) errors.push(credErr);
|
||||
|
||||
const server = { urls: cleanUrls.length === 1 ? cleanUrls[0] : cleanUrls };
|
||||
if (entry.username) server.username = String(entry.username);
|
||||
if (entry.credential) server.credential = String(entry.credential);
|
||||
|
||||
if (entryHasTurn && (!server.username || !server.credential)) {
|
||||
warnings.push(`${label}: TURN servers usually require a username and credential`);
|
||||
}
|
||||
|
||||
servers.push(server);
|
||||
});
|
||||
|
||||
return { servers, errors, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse free-form user input into ICE server entries.
|
||||
* Accepts either a JSON array of RTCIceServer-like objects, or one URL per line
|
||||
* (URL-only servers, e.g. public STUN). Returns normalized + validated output.
|
||||
*/
|
||||
export function parseIceServersInput(text) {
|
||||
if (typeof text !== 'string' || !text.trim()) {
|
||||
return { servers: [], errors: [], warnings: [] };
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return { servers: [], errors: ['Invalid JSON'], warnings: [] };
|
||||
}
|
||||
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
||||
return normalizeIceServers(arr);
|
||||
}
|
||||
|
||||
// Line-based: each non-empty line is a single URL-only server.
|
||||
const entries = trimmed
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.map(url => ({ urls: url }));
|
||||
return normalizeIceServers(entries);
|
||||
}
|
||||
|
||||
/** Does a normalized server list contain at least one TURN/TURNS server? */
|
||||
export function listHasTurn(servers) {
|
||||
if (!Array.isArray(servers)) return false;
|
||||
return servers.some(server => {
|
||||
const urls = Array.isArray(server?.urls) ? server.urls : [server?.urls];
|
||||
return urls.some(isTurnUrl);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Persistent, at-rest-encrypted storage for the user's custom ICE (STUN/TURN)
|
||||
// configuration. Persistence is OPTIONAL: the UI only calls saveIceSettings when
|
||||
// the user explicitly opts in ("Save on this device"). Session-only use never
|
||||
// touches this store — the settings live in React state and vanish on reload.
|
||||
//
|
||||
// At-rest protection model:
|
||||
// - A non-extractable AES-GCM device key is generated once and kept in
|
||||
// IndexedDB. It can never be exported back into JS, so a copy of the
|
||||
// on-disk database is useless without executing code in this exact origin.
|
||||
// - The settings blob (which may contain TURN credentials) is encrypted with
|
||||
// that key before being written.
|
||||
// This protects against disk/profile inspection. It does NOT protect against a
|
||||
// live code-execution compromise of the page (consistent with the project's
|
||||
// stated threat model — see SECURITY.md). Credentials are never persisted in
|
||||
// plaintext, and the user can delete them at any time via clearIceSettings().
|
||||
|
||||
const DB_NAME = 'securebit-net';
|
||||
const DB_VERSION = 1;
|
||||
const STORE = 'kv';
|
||||
const KEY_RECORD = 'ice-device-key';
|
||||
const SETTINGS_RECORD = 'ice-settings';
|
||||
const SETTINGS_VERSION = 1;
|
||||
|
||||
function isSupported() {
|
||||
return typeof indexedDB !== 'undefined' &&
|
||||
typeof crypto !== 'undefined' &&
|
||||
!!crypto.subtle;
|
||||
}
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let request;
|
||||
try {
|
||||
request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE);
|
||||
}
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
function idbGet(db, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readonly');
|
||||
const req = tx.objectStore(STORE).get(key);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
function idbPut(db, key, value) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(value, key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
function idbDelete(db, key) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).delete(key);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function getOrCreateDeviceKey(db) {
|
||||
const existing = await idbGet(db, KEY_RECORD);
|
||||
if (existing instanceof CryptoKey) {
|
||||
return existing;
|
||||
}
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false, // non-extractable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
await idbPut(db, KEY_RECORD, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist custom ICE settings, encrypted at rest.
|
||||
* @param {{ servers: Array, privacyMode: string }} settings
|
||||
*/
|
||||
export async function saveIceSettings(settings) {
|
||||
if (!isSupported()) throw new Error('Persistent storage is not available in this browser');
|
||||
|
||||
const db = await openDb();
|
||||
const key = await getOrCreateDeviceKey(db);
|
||||
|
||||
const payload = JSON.stringify({
|
||||
version: SETTINGS_VERSION,
|
||||
servers: Array.isArray(settings?.servers) ? settings.servers : [],
|
||||
privacyMode: settings?.privacyMode === 'relay-only' ? 'relay-only' : 'standard'
|
||||
});
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
new TextEncoder().encode(payload)
|
||||
);
|
||||
|
||||
await idbPut(db, SETTINGS_RECORD, {
|
||||
iv: Array.from(iv),
|
||||
data: Array.from(new Uint8Array(ciphertext))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and decrypt previously saved ICE settings.
|
||||
* Fails closed: returns null if absent, unsupported, or undecryptable.
|
||||
* @returns {Promise<{ servers: Array, privacyMode: string }|null>}
|
||||
*/
|
||||
export async function loadIceSettings() {
|
||||
if (!isSupported()) return null;
|
||||
|
||||
try {
|
||||
const db = await openDb();
|
||||
const record = await idbGet(db, SETTINGS_RECORD);
|
||||
if (!record || !Array.isArray(record.iv) || !Array.isArray(record.data)) {
|
||||
return null;
|
||||
}
|
||||
const key = await idbGet(db, KEY_RECORD);
|
||||
if (!(key instanceof CryptoKey)) return null;
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: new Uint8Array(record.iv) },
|
||||
key,
|
||||
new Uint8Array(record.data)
|
||||
);
|
||||
const parsed = JSON.parse(new TextDecoder().decode(plaintext));
|
||||
return {
|
||||
servers: Array.isArray(parsed.servers) ? parsed.servers : [],
|
||||
privacyMode: parsed.privacyMode === 'relay-only' ? 'relay-only' : 'standard'
|
||||
};
|
||||
} catch {
|
||||
// Corrupted or tampered record: fail closed.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete any persisted ICE settings (the device key is left in place). */
|
||||
export async function clearIceSettings() {
|
||||
if (!isSupported()) return;
|
||||
try {
|
||||
const db = await openDb();
|
||||
await idbDelete(db, SETTINGS_RECORD);
|
||||
} catch {
|
||||
// Best-effort deletion; nothing to surface to the user.
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasSavedIceSettings() {
|
||||
if (!isSupported()) return false;
|
||||
try {
|
||||
const db = await openDb();
|
||||
const record = await idbGet(db, SETTINGS_RECORD);
|
||||
return !!record;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user