+
)}
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 417a6b9c..510cc4c6 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -1,6 +1,74 @@
import Database from 'better-sqlite3';
import { encrypt_api_key } from '../services/apiKeyCrypto';
+/** Returns true if any collision was encountered (renamed row). */
+export function trimUserWhitespace(db: Database.Database): boolean {
+ type DirtyRow = { id: number; username?: string; email?: string };
+ let hadCollision = false;
+
+ const dirtyUsernames = db.prepare(
+ `SELECT id, username FROM users WHERE username != TRIM(username)`
+ ).all() as DirtyRow[];
+
+ for (const row of dirtyUsernames) {
+ const trimmed = row.username!.trim();
+ const collision = db.prepare(
+ `SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?`
+ ).get(trimmed, row.id) as { id: number } | undefined;
+
+ const final = collision ? `${trimmed}__migrated_${row.id}` : trimmed;
+ if (collision) {
+ hadCollision = true;
+ console.warn(
+ `[migration] WHITESPACE COLLISION username: user id=${row.id} ` +
+ `original=${JSON.stringify(row.username)} trimmed="${trimmed}" ` +
+ `collides with user id=${collision.id}. Renamed to "${final}". ` +
+ `Manual review required.`
+ );
+ } else {
+ console.warn(
+ `[migration] Trimmed username for user id=${row.id}: ` +
+ `${JSON.stringify(row.username)} → "${final}"`
+ );
+ }
+ db.prepare(`UPDATE users SET username = ? WHERE id = ?`).run(final, row.id);
+ }
+
+ const dirtyEmails = db.prepare(
+ `SELECT id, email FROM users WHERE email != TRIM(email)`
+ ).all() as DirtyRow[];
+
+ for (const row of dirtyEmails) {
+ const trimmed = row.email!.trim();
+ const collision = db.prepare(
+ `SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?`
+ ).get(trimmed, row.id) as { id: number } | undefined;
+
+ let final = trimmed;
+ if (collision) {
+ hadCollision = true;
+ const at = trimmed.lastIndexOf('@');
+ final = at > 0
+ ? `${trimmed.slice(0, at)}__migrated_${row.id}${trimmed.slice(at)}`
+ : `${trimmed}__migrated_${row.id}`;
+ console.warn(
+ `[migration] WHITESPACE COLLISION email: user id=${row.id} ` +
+ `original=${JSON.stringify(row.email)} trimmed="${trimmed}" ` +
+ `collides with user id=${collision.id}. Renamed to "${final}". ` +
+ `User cannot sign in with this email until manually corrected.`
+ );
+ } else {
+ console.warn(
+ `[migration] Trimmed email for user id=${row.id}: ` +
+ `${JSON.stringify(row.email)} → "${final}"`
+ );
+ }
+ db.prepare(`UPDATE users SET email = ? WHERE id = ?`).run(final, row.id);
+ }
+
+ return hadCollision;
+}
+
function runMigrations(db: Database.Database): void {
db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
const versionRow = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined;
@@ -2141,6 +2209,19 @@ function runMigrations(db: Database.Database): void {
> (SELECT day_number FROM days WHERE id = end_day_id)
`);
},
+ // prepare migration to nest + typeorm
+ () => {
+ db.exec(`CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL);`);
+ db.exec(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344');`);
+ db.exec(`INSERT INTO app_settings (key, value) VALUES ('app_version', '${process.env.APP_VERSION || '3.0.14'}')`);
+ },
+ // trim leading/trailing whitespace from stored usernames and emails
+ () => {
+ const hadCollision = trimUserWhitespace(db);
+ if (hadCollision) {
+ db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
+ }
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts
index 5cf49e79..310b869d 100644
--- a/server/src/db/schema.ts
+++ b/server/src/db/schema.ts
@@ -474,6 +474,8 @@ function createTables(db: Database.Database): void {
PRIMARY KEY (user_id, event_type, channel)
);
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
+
+ CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL);
`);
}
diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts
index f0fc5420..b2c50438 100644
--- a/server/src/services/adminService.ts
+++ b/server/src/services/adminService.ts
@@ -112,7 +112,9 @@ export function createUser(data: { username: string; email: string; password: st
}
export function updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
- const { username, email, role, password } = data;
+ const username = typeof data.username === 'string' ? data.username.trim() : data.username;
+ const email = typeof data.email === 'string' ? data.email.trim() : data.email;
+ const { role, password } = data;
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
if (!user) return { error: 'User not found', status: 404 };
diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts
index ba949481..71d61a5a 100644
--- a/server/src/services/authService.ts
+++ b/server/src/services/authService.ts
@@ -343,7 +343,9 @@ export function registerUser(body: {
password?: string;
invite_token?: string;
}): { error?: string; status?: number; token?: string; user?: Record
; auditUserId?: number; auditDetails?: Record } {
- const { username, email, password, invite_token } = body;
+ const username = typeof body.username === 'string' ? body.username.trim() : '';
+ const email = typeof body.email === 'string' ? body.email.trim() : '';
+ const { password, invite_token } = body;
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts
index 42c0c5cd..edce054f 100644
--- a/server/src/services/oidcService.ts
+++ b/server/src/services/oidcService.ts
@@ -350,7 +350,7 @@ export function findOrCreateUser(
config: OidcConfig,
inviteToken?: string,
): { user: User } | { error: string } {
- const email = userInfo.email!.toLowerCase();
+ const email = userInfo.email!.trim().toLowerCase();
const name = userInfo.name || userInfo.preferred_username || email.split('@')[0];
const sub = userInfo.sub;
diff --git a/server/src/systemNotices/registry.ts b/server/src/systemNotices/registry.ts
index 19f056ce..4f5320d8 100644
--- a/server/src/systemNotices/registry.ts
+++ b/server/src/systemNotices/registry.ts
@@ -1,4 +1,11 @@
import type { SystemNotice } from './types.js';
+import { registerPredicate } from './conditions.js';
+import { db } from '../db/database.js';
+
+registerPredicate('whitespace-collision-detected', () => {
+ const row = db.prepare("SELECT value FROM app_settings WHERE key = 'whitespace_migration_collision'").get() as { value: string } | undefined;
+ return row?.value === 'true';
+});
/**
* SYSTEM NOTICE REGISTRY
@@ -124,6 +131,26 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
maxVersion: '4.0.0',
},
+ // ── 3.0.14 admin notice — whitespace migration collision ───────────────────
+
+ {
+ id: 'v3014-whitespace-collision',
+ display: 'banner',
+ severity: 'warn',
+ icon: 'AlertTriangle',
+ titleKey: 'system_notice.v3014_whitespace_collision.title',
+ bodyKey: 'system_notice.v3014_whitespace_collision.body',
+ dismissible: true,
+ conditions: [
+ { kind: 'existingUserBeforeVersion', version: '3.0.14' },
+ { kind: 'role', roles: ['admin'] },
+ { kind: 'custom', id: 'whitespace-collision-detected' },
+ ],
+ publishedAt: '2026-05-03T00:00:00Z',
+ priority: 85,
+ minVersion: '3.0.14',
+ },
+
// ── Onboarding ─────────────────────────────────────────────────────────────
{
diff --git a/server/src/utils/ssrfGuard.ts b/server/src/utils/ssrfGuard.ts
index 19ed98dc..f9b4255f 100644
--- a/server/src/utils/ssrfGuard.ts
+++ b/server/src/utils/ssrfGuard.ts
@@ -66,11 +66,6 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
const hostname = url.hostname.toLowerCase();
- // Block internal hostname suffixes (no override — these are too easy to abuse)
- if (isInternalHostname(hostname) && hostname !== 'localhost') {
- return { allowed: false, isPrivate: false, error: 'Requests to .local/.internal domains are not allowed' };
- }
-
// Resolve hostname to IP
let resolvedIp: string;
try {
diff --git a/server/tests/integration/admin.test.ts b/server/tests/integration/admin.test.ts
index beeaa0a1..e96d2234 100644
--- a/server/tests/integration/admin.test.ts
+++ b/server/tests/integration/admin.test.ts
@@ -368,6 +368,53 @@ describe('Admin user management', () => {
});
});
+// ─────────────────────────────────────────────────────────────────────────────
+// Admin user management — whitespace normalization
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('Admin user management — whitespace normalization', () => {
+ it('ADMIN-UPDATE-TRIM-1 — PUT /admin/users/:id trims username before storing', async () => {
+ const { user: admin } = createAdmin(testDb);
+ const { user } = createUser(testDb);
+
+ const res = await request(app)
+ .put(`/api/admin/users/${user.id}`)
+ .set('Cookie', authCookie(admin.id))
+ .send({ username: ' trimmedadmin ' });
+
+ expect(res.status).toBe(200);
+ const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string };
+ expect(row.username).toBe('trimmedadmin');
+ });
+
+ it('ADMIN-UPDATE-TRIM-2 — PUT /admin/users/:id trims email before storing', async () => {
+ const { user: admin } = createAdmin(testDb);
+ const { user } = createUser(testDb);
+
+ const res = await request(app)
+ .put(`/api/admin/users/${user.id}`)
+ .set('Cookie', authCookie(admin.id))
+ .send({ email: ' newemail@example.com ' });
+
+ expect(res.status).toBe(200);
+ const row = testDb.prepare('SELECT email FROM users WHERE id = ?').get(user.id) as { email: string };
+ expect(row.email).toBe('newemail@example.com');
+ });
+
+ it('ADMIN-UPDATE-TRIM-3 — PUT /admin/users/:id with whitespace-padded username that trims to existing returns 409', async () => {
+ const { user: admin } = createAdmin(testDb);
+ const { user: existing } = createUser(testDb, { username: 'carol' });
+ const { user: target } = createUser(testDb);
+
+ const res = await request(app)
+ .put(`/api/admin/users/${target.id}`)
+ .set('Cookie', authCookie(admin.id))
+ .send({ username: ` ${existing.username} ` });
+
+ expect(res.status).toBe(409);
+ });
+});
+
// ─────────────────────────────────────────────────────────────────────────────
// System stats
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts
index d60dbc0e..fb3c7ea2 100644
--- a/server/tests/integration/auth.test.ts
+++ b/server/tests/integration/auth.test.ts
@@ -218,6 +218,54 @@ describe('Registration', () => {
});
});
+// ─────────────────────────────────────────────────────────────────────────────
+// Registration — whitespace normalization
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('Registration — whitespace normalization', () => {
+ it('AUTH-REG-TRIM-1 — username with surrounding whitespace is trimmed before storage', async () => {
+ const res = await request(app).post('/api/auth/register').send({
+ username: ' trimmeduser ',
+ email: 'trimmed@example.com',
+ password: 'Str0ng!Pass',
+ });
+ expect(res.status).toBe(201);
+ const row = testDb.prepare('SELECT username FROM users WHERE email = ?').get('trimmed@example.com') as { username: string };
+ expect(row.username).toBe('trimmeduser');
+ });
+
+ it('AUTH-REG-TRIM-2 — email with surrounding whitespace is trimmed before storage', async () => {
+ const res = await request(app).post('/api/auth/register').send({
+ username: 'emailtrimuser',
+ email: ' emailtrim@example.com ',
+ password: 'Str0ng!Pass',
+ });
+ expect(res.status).toBe(201);
+ const row = testDb.prepare('SELECT email FROM users WHERE username = ?').get('emailtrimuser') as { email: string };
+ expect(row.email).toBe('emailtrim@example.com');
+ });
+
+ it('AUTH-REG-TRIM-3 — whitespace-padded username that trims to existing username returns 409', async () => {
+ createUser(testDb, { username: 'alice', email: 'alice@example.com' });
+ const res = await request(app).post('/api/auth/register').send({
+ username: ' alice ',
+ email: 'alice2@example.com',
+ password: 'Str0ng!Pass',
+ });
+ expect(res.status).toBe(409);
+ });
+
+ it('AUTH-REG-TRIM-4 — whitespace-padded email that trims to existing email returns 409', async () => {
+ createUser(testDb, { username: 'bob', email: 'bob@example.com' });
+ const res = await request(app).post('/api/auth/register').send({
+ username: 'bob2',
+ email: ' bob@example.com ',
+ password: 'Str0ng!Pass',
+ });
+ expect(res.status).toBe(409);
+ });
+});
+
// ─────────────────────────────────────────────────────────────────────────────
// Session / Me
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts
index 40179329..5bc88fae 100644
--- a/server/tests/integration/systemNotices.test.ts
+++ b/server/tests/integration/systemNotices.test.ts
@@ -39,7 +39,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
-import { createUser } from '../helpers/factories';
+import { createUser, createAdmin } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
import type { SystemNotice } from '../../src/systemNotices/types';
@@ -242,3 +242,129 @@ describe('POST /api/system-notices/:id/dismiss', () => {
}
});
});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// v3014-whitespace-collision notice
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Helper: creates an admin user whose first_seen_version is before 3.0.14
+ * (so existingUserBeforeVersion('3.0.14') passes) and whose login_count is
+ * high enough to suppress the firstLogin and v3-upgrade notice conditions.
+ */
+function setupCollisionAdmin() {
+ const { user } = createAdmin(testDb);
+ testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
+ return user;
+}
+
+describe('v3014-whitespace-collision notice', () => {
+ const NOTICE_ID = 'v3014-whitespace-collision';
+ const originalAppVersion = process.env.APP_VERSION;
+
+ beforeEach(() => {
+ process.env.APP_VERSION = '3.0.14';
+ });
+
+ afterEach(() => {
+ if (originalAppVersion === undefined) {
+ delete process.env.APP_VERSION;
+ } else {
+ process.env.APP_VERSION = originalAppVersion;
+ }
+ });
+
+ it('SN-COLLISION-1 — shown to admin when collision flag is set and user predates 3.0.14', async () => {
+ const user = setupCollisionAdmin();
+ testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined();
+ });
+
+ it('SN-COLLISION-2 — hidden when collision flag is absent', async () => {
+ const user = setupCollisionAdmin();
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
+ });
+
+ it('SN-COLLISION-3 — hidden when collision flag is explicitly false', async () => {
+ const user = setupCollisionAdmin();
+ testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'false')").run();
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
+ });
+
+ it('SN-COLLISION-4 — hidden for non-admin user even when collision flag is set', async () => {
+ const { user } = createUser(testDb);
+ testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
+ testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
+ });
+
+ it('SN-COLLISION-5 — hidden for user whose first_seen_version is >= 3.0.14 (new account)', async () => {
+ const { user } = createAdmin(testDb);
+ testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.14', user.id);
+ testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
+ });
+
+ it('SN-COLLISION-6 — hidden when app version is below 3.0.14', async () => {
+ process.env.APP_VERSION = '3.0.13';
+ const user = setupCollisionAdmin();
+ testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
+
+ const res = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
+ });
+
+ it('SN-COLLISION-7 — hidden after admin dismisses it', async () => {
+ const user = setupCollisionAdmin();
+ testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
+
+ const before = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+ expect(before.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined();
+
+ const dismiss = await request(app)
+ .post(`/api/system-notices/${NOTICE_ID}/dismiss`)
+ .set('Cookie', authCookie(user.id));
+ expect(dismiss.status).toBe(204);
+
+ const after = await request(app)
+ .get('/api/system-notices/active')
+ .set('Cookie', authCookie(user.id));
+ expect(after.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
+ });
+});
diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts
index 01c718ee..487407cb 100644
--- a/server/tests/integration/trips.test.ts
+++ b/server/tests/integration/trips.test.ts
@@ -677,6 +677,20 @@ describe('Trip members', () => {
expect(res.body.error).toMatch(/already/i);
});
+ it('TRIP-013 — Adding a member by whitespace-padded username resolves correctly → 201', async () => {
+ const { user: owner } = createUser(testDb);
+ const { user: invitee } = createUser(testDb, { username: 'paddeduser' });
+ const trip = createTrip(testDb, owner.id, { title: 'Padded Trip' });
+
+ const res = await request(app)
+ .post(`/api/trips/${trip.id}/members`)
+ .set('Cookie', authCookie(owner.id))
+ .send({ identifier: ' paddeduser ' });
+
+ expect(res.status).toBe(201);
+ expect(res.body.member.id).toBe(invitee.id);
+ });
+
it('TRIP-014 — DELETE /api/trips/:id/members/:userId removes a member → 200', async () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
diff --git a/server/tests/unit/services/trimUserWhitespace.test.ts b/server/tests/unit/services/trimUserWhitespace.test.ts
new file mode 100644
index 00000000..0ac5afde
--- /dev/null
+++ b/server/tests/unit/services/trimUserWhitespace.test.ts
@@ -0,0 +1,122 @@
+/**
+ * Unit tests for trimUserWhitespace — the backfill migration that normalises
+ * leading/trailing whitespace in stored usernames and emails.
+ * Tests TRIM-MIG-001 through TRIM-MIG-010.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import Database from 'better-sqlite3';
+import { trimUserWhitespace } from '../../../src/db/migrations';
+
+function makeDb() {
+ const db = new Database(':memory:');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec(`
+ CREATE TABLE users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT UNIQUE NOT NULL,
+ email TEXT UNIQUE NOT NULL,
+ password_hash TEXT NOT NULL DEFAULT 'x',
+ role TEXT NOT NULL DEFAULT 'user'
+ )
+ `);
+ return db;
+}
+
+function insert(db: Database.Database, username: string, email: string): number {
+ const r = db.prepare('INSERT INTO users (username, email) VALUES (?, ?)').run(username, email);
+ return Number(r.lastInsertRowid);
+}
+
+function row(db: Database.Database, id: number) {
+ return db.prepare('SELECT username, email FROM users WHERE id = ?').get(id) as { username: string; email: string };
+}
+
+describe('trimUserWhitespace — clean data (no-op)', () => {
+ it('TRIM-MIG-001 — leaves already-clean rows untouched', () => {
+ const db = makeDb();
+ const id = insert(db, 'alice', 'alice@example.com');
+ trimUserWhitespace(db);
+ expect(row(db, id)).toEqual({ username: 'alice', email: 'alice@example.com' });
+ });
+});
+
+describe('trimUserWhitespace — non-colliding dirty rows', () => {
+ it('TRIM-MIG-002 — trims trailing whitespace from username', () => {
+ const db = makeDb();
+ const id = insert(db, 'alice ', 'alice@example.com');
+ trimUserWhitespace(db);
+ expect(row(db, id).username).toBe('alice');
+ });
+
+ it('TRIM-MIG-003 — trims leading whitespace from username', () => {
+ const db = makeDb();
+ const id = insert(db, ' alice', 'alice@example.com');
+ trimUserWhitespace(db);
+ expect(row(db, id).username).toBe('alice');
+ });
+
+ it('TRIM-MIG-004 — trims surrounding whitespace from email', () => {
+ const db = makeDb();
+ const id = insert(db, 'alice', ' alice@example.com ');
+ trimUserWhitespace(db);
+ expect(row(db, id).email).toBe('alice@example.com');
+ });
+
+ it('TRIM-MIG-005 — emits a console.warn for each trimmed row', () => {
+ const db = makeDb();
+ insert(db, 'bob ', 'bob@example.com');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ trimUserWhitespace(db);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('[migration] Trimmed username'));
+ warn.mockRestore();
+ });
+});
+
+describe('trimUserWhitespace — username collision handling', () => {
+ it('TRIM-MIG-006 — renames the dirty row to __migrated_ on collision', () => {
+ const db = makeDb();
+ insert(db, 'carol', 'carol@example.com');
+ const dirtyId = insert(db, 'carol ', 'carol2@example.com');
+ trimUserWhitespace(db);
+ expect(row(db, dirtyId).username).toBe(`carol__migrated_${dirtyId}`);
+ });
+
+ it('TRIM-MIG-007 — emits a WHITESPACE COLLISION warning for username collision', () => {
+ const db = makeDb();
+ insert(db, 'dan', 'dan@example.com');
+ insert(db, 'dan ', 'dan2@example.com');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ trimUserWhitespace(db);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('WHITESPACE COLLISION username'));
+ warn.mockRestore();
+ });
+
+ it('TRIM-MIG-008 — the renamed value does not conflict with the existing clean row', () => {
+ const db = makeDb();
+ const cleanId = insert(db, 'eve', 'eve@example.com');
+ const dirtyId = insert(db, 'eve ', 'eve2@example.com');
+ trimUserWhitespace(db);
+ expect(row(db, cleanId).username).toBe('eve');
+ expect(row(db, dirtyId).username).toBe(`eve__migrated_${dirtyId}`);
+ });
+});
+
+describe('trimUserWhitespace — email collision handling', () => {
+ it('TRIM-MIG-009 — renames dirty email as __migrated_@ on collision', () => {
+ const db = makeDb();
+ insert(db, 'frank', 'frank@example.com');
+ const dirtyId = insert(db, 'frank2', ' frank@example.com ');
+ trimUserWhitespace(db);
+ expect(row(db, dirtyId).email).toBe(`frank__migrated_${dirtyId}@example.com`);
+ });
+
+ it('TRIM-MIG-010 — emits a WHITESPACE COLLISION warning for email collision', () => {
+ const db = makeDb();
+ insert(db, 'grace', 'grace@example.com');
+ insert(db, 'grace2', 'grace@example.com ');
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ trimUserWhitespace(db);
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('WHITESPACE COLLISION email'));
+ warn.mockRestore();
+ });
+});
diff --git a/wiki/Internal-Network-Access.md b/wiki/Internal-Network-Access.md
index dc93e134..981ae63d 100644
--- a/wiki/Internal-Network-Access.md
+++ b/wiki/Internal-Network-Access.md
@@ -17,13 +17,9 @@ These ranges are blocked regardless of any setting:
| `169.254.0.0/16`, `fe80::/10` | Link-local / cloud metadata endpoints |
| `::ffff:127.x.x.x`, `::ffff:169.254.x.x` | IPv4-mapped loopback and link-local |
-In addition, hostnames ending in `.local` or `.internal` are always blocked regardless of `ALLOW_INTERNAL_NETWORK`. These suffixes are readily abused for hostname-based bypasses.
-
-The hostname `localhost` is not blocked at the hostname stage, but it resolves to `127.0.0.1` which is caught by the loopback rule above and is therefore always blocked.
-
## Blocked unless `ALLOW_INTERNAL_NETWORK=true`
-| Range | Description |
+| Range / Hostname | Description |
|---|---|
| `10.0.0.0/8` | RFC-1918 private |
| `172.16.0.0/12` | RFC-1918 private |
@@ -31,6 +27,11 @@ The hostname `localhost` is not blocked at the hostname stage, but it resolves t
| `100.64.0.0/10` | CGNAT / Tailscale shared address space |
| `fc00::/7` | IPv6 ULA |
| IPv4-mapped RFC-1918 variants | e.g. `::ffff:10.x`, `::ffff:192.168.x` |
+| `*.local`, `*.internal` hostnames | mDNS / internal DNS suffixes (e.g. Docker service names, LAN hosts) |
+
+The hostname `localhost` is not blocked at the hostname stage, but it resolves to `127.0.0.1` which is caught by the loopback rule above and is therefore always blocked.
+
+`*.local` and `*.internal` hostnames are permitted when `ALLOW_INTERNAL_NETWORK=true` — the guard still resolves them to an IP and enforces all IP-level rules, so any such hostname that resolves to a loopback or link-local address remains blocked regardless.
## When to enable