From 2be3357af6a93dd75710c6ecc3bf4a1cbff48252 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:49:25 +0000 Subject: [PATCH] fix(owner): preserve password whitespace during login --- backend/src/api/routes/owner.test.ts | 33 +++++++++++++++++++ frontend/src/pages/OwnerPortalPage.tsx | 18 +++++----- .../src/pages/owner/ownerPortalModel.test.ts | 12 +++++++ frontend/src/pages/owner/ownerPortalModel.ts | 18 ++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/backend/src/api/routes/owner.test.ts b/backend/src/api/routes/owner.test.ts index f3889ff..dd3b2e2 100644 --- a/backend/src/api/routes/owner.test.ts +++ b/backend/src/api/routes/owner.test.ts @@ -82,3 +82,36 @@ test('owner session route rejects cookies from an older credential generation', ownerServer.restore(); } }); + +test('owner login verifies the exact password string, including surrounding whitespace', async () => { + let receivedCredentials: [string, string] | undefined; + const ownerServer = await startOwnerRoutes({ + verifyCredentials: async (username, password) => { + receivedCredentials = [username, password]; + return true; + }, + }); + + try { + const csrfResponse = await fetch(`${ownerServer.baseUrl}/owner/csrf`); + const { csrfToken } = await csrfResponse.json() as { csrfToken: string }; + const csrfCookie = csrfResponse.headers.get('set-cookie')?.split(';', 1)[0]; + assert.ok(csrfCookie); + + const response = await fetch(`${ownerServer.baseUrl}/owner/login`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + cookie: csrfCookie, + 'x-csrf-token': csrfToken, + }, + body: JSON.stringify({ mqttUsername: ' owner ', mqttPassword: ' password ' }), + }); + + assert.equal(response.status, 200); + assert.deepEqual(receivedCredentials, ['owner', ' password ']); + } finally { + await ownerServer.close(); + ownerServer.restore(); + } +}); diff --git a/frontend/src/pages/OwnerPortalPage.tsx b/frontend/src/pages/OwnerPortalPage.tsx index c5f777c..03161c2 100644 --- a/frontend/src/pages/OwnerPortalPage.tsx +++ b/frontend/src/pages/OwnerPortalPage.tsx @@ -22,6 +22,8 @@ import { lastHopSeriesCache, linkBadge, nodeRoleLabel, + ownerLoginCredentials, + ownerLoginValidationError, publishOwnerSession, type LastHopStrengthPoint, type MappedPeer, @@ -128,13 +130,14 @@ export const OwnerPortalPage: React.FC = () => { const handleLogin = (event: FormEvent) => { event.preventDefault(); - if (!mqttUsername.trim() || !mqttPassword) { - setError('Enter your MQTT username and password.'); + const validationError = ownerLoginValidationError(mqttUsername, mqttPassword); + if (validationError) { + setError(validationError); return; } setSubmitting(true); setError(null); - const loginUsername = mqttUsername.trim(); + const credentials = ownerLoginCredentials(mqttUsername, mqttPassword); fetchOwnerCsrfToken() .then((csrfToken) => fetchJson('/api/owner/login', { method: 'POST', @@ -142,10 +145,7 @@ export const OwnerPortalPage: React.FC = () => { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken, }, - body: JSON.stringify({ - mqttUsername: loginUsername, - mqttPassword, - }), + body: JSON.stringify(credentials), }, { timeoutMs: 15_000, maxBytes: 2 * 1024 * 1024, @@ -155,8 +155,8 @@ export const OwnerPortalPage: React.FC = () => { .then((json) => { lastHopSeriesCache.clear(); setDashboard(json.dashboard); - setOwnerSessionKey(json.mqttUsername ?? loginUsername); - publishOwnerSession(json.mqttUsername ?? loginUsername); + setOwnerSessionKey(json.mqttUsername ?? credentials.mqttUsername); + publishOwnerSession(json.mqttUsername ?? credentials.mqttUsername); if (json.dashboard.nodes[0]?.node_id) { setSelectedNodeId(json.dashboard.nodes[0].node_id); } diff --git a/frontend/src/pages/owner/ownerPortalModel.test.ts b/frontend/src/pages/owner/ownerPortalModel.test.ts index 9711e48..bdbeaf2 100644 --- a/frontend/src/pages/owner/ownerPortalModel.test.ts +++ b/frontend/src/pages/owner/ownerPortalModel.test.ts @@ -9,8 +9,20 @@ import { isOwnerSessionResponse, isValidMapCoord, nodeRoleLabel, + ownerLoginCredentials, + ownerLoginValidationError, } from './ownerPortalModel.js'; +test('owner login trims only the username and preserves password whitespace', () => { + assert.equal(ownerLoginValidationError(' owner ', ' password '), null); + assert.deepEqual(ownerLoginCredentials(' owner ', ' password '), { + mqttUsername: 'owner', + mqttPassword: ' password ', + }); + assert.match(ownerLoginValidationError('owner', '\tpassword') ?? '', /invalid characters/); + assert.match(ownerLoginValidationError('owner', 'x'.repeat(129)) ?? '', /too long/); +}); + test('owner map coordinate policy rejects placeholders and invalid coordinates', () => { assert.equal(isValidMapCoord(51.5, -0.1), true); assert.equal(isValidMapCoord(0, 0), false); diff --git a/frontend/src/pages/owner/ownerPortalModel.ts b/frontend/src/pages/owner/ownerPortalModel.ts index 64b02c6..f9ce3b3 100644 --- a/frontend/src/pages/owner/ownerPortalModel.ts +++ b/frontend/src/pages/owner/ownerPortalModel.ts @@ -21,6 +21,24 @@ export function nodeRoleLabel(role: number | null): string { return 'Repeater'; } +export type OwnerLoginCredentials = { mqttUsername: string; mqttPassword: string }; + +export function ownerLoginValidationError(mqttUsername: string, mqttPassword: string): string | null { + const normalizedUsername = mqttUsername.trim(); + if (!normalizedUsername || !mqttPassword) return 'Enter your MQTT username and password.'; + if (normalizedUsername.length > 128 || mqttPassword.length > 128) { + return 'MQTT username or password is too long.'; + } + if (/[\u0000-\u001F\u007F]/.test(normalizedUsername) || /[\u0000-\u001F\u007F]/.test(mqttPassword)) { + return 'MQTT username or password contains invalid characters.'; + } + return null; +} + +export function ownerLoginCredentials(mqttUsername: string, mqttPassword: string): OwnerLoginCredentials { + return { mqttUsername: mqttUsername.trim(), mqttPassword }; +} + export type OwnerDashboard = { nodes: OwnerNode[]; };