fix(owner): preserve password whitespace during login

This commit is contained in:
gadgethd committed 2026-09-23 11:49:25 +00:00
1 parent 771fc6f613
commit 2be3357af6
4 files changed
+72 -9

No files matched your search

+33
View File
@@ -82,3 +82,36 @@ test('owner session route rejects cookies from an older credential generation',
ownerServer.restore(); 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();
}
});
+9 -9
View File
@@ -22,6 +22,8 @@ import {
lastHopSeriesCache, lastHopSeriesCache,
linkBadge, linkBadge,
nodeRoleLabel, nodeRoleLabel,
ownerLoginCredentials,
ownerLoginValidationError,
publishOwnerSession, publishOwnerSession,
type LastHopStrengthPoint, type LastHopStrengthPoint,
type MappedPeer, type MappedPeer,
@@ -128,13 +130,14 @@ export const OwnerPortalPage: React.FC = () => {
const handleLogin = (event: FormEvent) => { const handleLogin = (event: FormEvent) => {
event.preventDefault(); event.preventDefault();
if (!mqttUsername.trim() || !mqttPassword) { const validationError = ownerLoginValidationError(mqttUsername, mqttPassword);
setError('Enter your MQTT username and password.'); if (validationError) {
setError(validationError);
return; return;
} }
setSubmitting(true); setSubmitting(true);
setError(null); setError(null);
const loginUsername = mqttUsername.trim(); const credentials = ownerLoginCredentials(mqttUsername, mqttPassword);
fetchOwnerCsrfToken() fetchOwnerCsrfToken()
.then((csrfToken) => fetchJson<OwnerSessionResponse>('/api/owner/login', { .then((csrfToken) => fetchJson<OwnerSessionResponse>('/api/owner/login', {
method: 'POST', method: 'POST',
@@ -142,10 +145,7 @@ export const OwnerPortalPage: React.FC = () => {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken, 'X-CSRF-Token': csrfToken,
}, },
body: JSON.stringify({ body: JSON.stringify(credentials),
mqttUsername: loginUsername,
mqttPassword,
}),
}, { }, {
timeoutMs: 15_000, timeoutMs: 15_000,
maxBytes: 2 * 1024 * 1024, maxBytes: 2 * 1024 * 1024,
@@ -155,8 +155,8 @@ export const OwnerPortalPage: React.FC = () => {
.then((json) => { .then((json) => {
lastHopSeriesCache.clear(); lastHopSeriesCache.clear();
setDashboard(json.dashboard); setDashboard(json.dashboard);
setOwnerSessionKey(json.mqttUsername ?? loginUsername); setOwnerSessionKey(json.mqttUsername ?? credentials.mqttUsername);
publishOwnerSession(json.mqttUsername ?? loginUsername); publishOwnerSession(json.mqttUsername ?? credentials.mqttUsername);
if (json.dashboard.nodes[0]?.node_id) { if (json.dashboard.nodes[0]?.node_id) {
setSelectedNodeId(json.dashboard.nodes[0].node_id); setSelectedNodeId(json.dashboard.nodes[0].node_id);
} }
@@ -9,8 +9,20 @@ import {
isOwnerSessionResponse, isOwnerSessionResponse,
isValidMapCoord, isValidMapCoord,
nodeRoleLabel, nodeRoleLabel,
ownerLoginCredentials,
ownerLoginValidationError,
} from './ownerPortalModel.js'; } 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', () => { test('owner map coordinate policy rejects placeholders and invalid coordinates', () => {
assert.equal(isValidMapCoord(51.5, -0.1), true); assert.equal(isValidMapCoord(51.5, -0.1), true);
assert.equal(isValidMapCoord(0, 0), false); assert.equal(isValidMapCoord(0, 0), false);
@@ -21,6 +21,24 @@ export function nodeRoleLabel(role: number | null): string {
return 'Repeater'; 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 = { export type OwnerDashboard = {
nodes: OwnerNode[]; nodes: OwnerNode[];
}; };