Bug fixes - May 2nd 2026 (#941)

* fix: collab chat input hidden by mobile bottom nav bar

Closes #939

* chore: prepare database for nest + typeorm

* fix(ssrf): relax internal network resolution (#947)

* docs(ssrf): update Internal-Network-Access wiki to reflect relaxed guard

Loopback, link-local, and .local/.internal hostnames are now all
overridable with ALLOW_INTERNAL_NETWORK=true (commit 9a08368). Merge
the two-tier "always blocked / conditionally blocked" structure into a
single table, add a warning about cloud metadata exposure.

* fix(ssrf): let .local/.internal hostnames pass to IP-level checks

The pre-DNS hostname block was redundant: any .local/.internal host
that resolves to a private IP is already gated by isPrivateNetwork +
ALLOW_INTERNAL_NETWORK, and any that resolves to loopback/link-local
is caught by isAlwaysBlocked unconditionally.

Dropping the hostname pre-check means Docker/LAN deployments can reach
services on .local hostnames (e.g. immich.local) with
ALLOW_INTERNAL_NETWORK=true, while loopback and link-local IPs
(including 169.254.169.254) remain hard-blocked with no override.

Reverts the isAlwaysBlocked guard loosening from 9a08368.

* fix(auth): trim username and email on all write paths

Self-registration stored values verbatim, so trailing whitespace could
produce rows that lookup code (which trims input) silently misses.
Trim username and email before validation and INSERT in registerUser,
adminService.updateUser, and oidcService.findOrCreateUser. updateSettings
and adminService.createUser already trimmed correctly.

Adds a one-shot backfill migration (trimUserWhitespace) that trims
existing dirty rows; collisions are resolved by appending __migrated_<id>
to the value with a loud console.warn so operators can review affected
accounts.

18 new tests covering registration trim, duplicate detection, admin
update trim, trip-member lookup regression, and all migration branches.

* feat(notices): add v3014-whitespace-collision admin notice

Adds a dismissible banner for admins on v3.0.14+ that fires only when
the whitespace-trimming migration detected a username/email collision
(stored in app_settings as whitespace_migration_collision=true).

Notice conditions: existingUserBeforeVersion(3.0.14) + role=admin +
custom predicate reading the app_settings flag. Predicate registered in
registry.ts; migration step writes the flag when hadCollision=true.

All 15 translation files updated with title/body keys.
7 integration tests added (SN-COLLISION-1 through -7) covering all
condition branches: shown when all conditions met, hidden when flag
absent/false, hidden for non-admin, hidden for new user, hidden below
min app version, hidden after dismissal.
This commit is contained in:
Julien G.
2026-05-03 17:39:45 +02:00
committed by GitHub
parent 4ae4e0c676
commit 6072b969d6
30 changed files with 529 additions and 16 deletions
+81
View File
@@ -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) {
+2
View File
@@ -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);
`);
}
+3 -1
View File
@@ -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 };
+3 -1
View File
@@ -343,7 +343,9 @@ export function registerUser(body: {
password?: string;
invite_token?: string;
}): { error?: string; status?: number; token?: string; user?: Record<string, unknown>; auditUserId?: number; auditDetails?: Record<string, unknown> } {
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;
+1 -1
View File
@@ -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;
+27
View File
@@ -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 ─────────────────────────────────────────────────────────────
{
-5
View File
@@ -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 {