From 74d243d8e0c7df03e4e853d8d41106fece34c7e0 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 12 Nov 2026 16:58:24 +0100 Subject: [PATCH] fix(airtrail): a new host does not inherit the stored API key Saving the AirTrail settings with a different URL and the key field left masked or blank kept the stored key, so the next sync sent the old instance's key to the new host. The key now goes with the host, as it already did for the connection test and for Dawarich: a new origin without a new key clears it, a path edit on the same origin keeps it. The key is cleared before the new address is written. --- client/src/native/nativeApp.ts | 2 +- .../src/nest/integrations/airtrail.service.ts | 11 ++++++ .../tests/unit/nest/airtrail.service.test.ts | 36 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/client/src/native/nativeApp.ts b/client/src/native/nativeApp.ts index 173ddec0b..adec0cf89 100644 --- a/client/src/native/nativeApp.ts +++ b/client/src/native/nativeApp.ts @@ -115,7 +115,6 @@ async function fetchServerFeatures(): Promise<{ minNativeShellApi?: unknown }> { return (await res.json()) as { minNativeShellApi?: unknown }; } -/** The scrolled list in the middle of the screen, else the page, back to its top. */ /** The launch link this web view has already followed, so a reload does not follow it again. */ const LAUNCH_LINK_KEY = 'trek_launch_link_done'; @@ -139,6 +138,7 @@ async function openLaunchLink(): Promise { } } +/** The scrolled list in the middle of the screen, else the page, back to its top. */ export function scrollToTop(): void { let el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) as HTMLElement | null; while (el && el !== document.body) { diff --git a/server/src/nest/integrations/airtrail.service.ts b/server/src/nest/integrations/airtrail.service.ts index a4d03b50b..adb97c8b5 100644 --- a/server/src/nest/integrations/airtrail.service.ts +++ b/server/src/nest/integrations/airtrail.service.ts @@ -119,6 +119,17 @@ export class AirtrailService { const provided = (apiKey || '').trim(); const newKey = provided && provided !== KEY_MASK ? maybe_encrypt_api_key(provided) : undefined; + // A key belongs to the instance it was issued on, as in testConnection. A + // new host with the key field left masked or blank would otherwise send the + // old instance's key to the new one on the next sync, so the key goes with + // the host. A path edit on the same origin keeps it. The key is cleared + // before the new address is written, so no moment stores the new address + // beside the old key. + if (newKey === undefined) { + const previousUrl = (await this.readRow(userId))?.airtrail_url ?? null; + if (movedHost(previousUrl, trimmedUrl)) await this.airtrailRepo.clearAirtrailApiKey(userId); + } + if (newKey !== undefined) { await this.airtrailRepo.setAirtrailSettingsWithKey( userId, diff --git a/server/tests/unit/nest/airtrail.service.test.ts b/server/tests/unit/nest/airtrail.service.test.ts index 54021d271..67597bd72 100644 --- a/server/tests/unit/nest/airtrail.service.test.ts +++ b/server/tests/unit/nest/airtrail.service.test.ts @@ -89,3 +89,39 @@ describe('AirtrailService — at-rest encryption (3h L3)', () => { expect((await svc.getAirtrailCredentials(user.id))?.apiKey).toBe('second-key'); }); }); + +describe('AirtrailService — the stored key stays with its host (GHSA-mwf5-5px8-c9h5)', () => { + const MASK = '••••••••'; + + it('AIRTRAIL-SVC-HOST-001: a new host saved with the key masked or blank drops the stored key', async () => { + for (const keyField of [MASK, '', undefined]) { + const { user } = createUser(testDb); + await svc.saveSettings(user.id, 'https://airtrail-a.example.test', 'key-for-a', false, false, null); + + await svc.saveSettings(user.id, 'https://other-host.example.test', keyField, false, false, null); + + expect(await rawAirtrailKey(user.id)).toBeNull(); + expect(await svc.getAirtrailCredentials(user.id)).toBeNull(); + expect((await readUser(t, user.id)).airtrail_url).toBe('https://other-host.example.test'); + } + }); + + it('AIRTRAIL-SVC-HOST-002: a path edit on the same origin keeps the stored key', async () => { + const { user } = createUser(testDb); + await svc.saveSettings(user.id, 'https://airtrail-a.example.test', 'key-for-a', false, false, null); + + await svc.saveSettings(user.id, 'https://airtrail-a.example.test/api', MASK, false, false, null); + + expect((await svc.getAirtrailCredentials(user.id))?.apiKey).toBe('key-for-a'); + }); + + it('AIRTRAIL-SVC-HOST-003: a new host saved with a new key stores that key', async () => { + const { user } = createUser(testDb); + await svc.saveSettings(user.id, 'https://airtrail-a.example.test', 'key-for-a', false, false, null); + + await svc.saveSettings(user.id, 'https://other-host.example.test', 'key-for-b', false, false, null); + + const creds = await svc.getAirtrailCredentials(user.id); + expect(creds).toMatchObject({ baseUrl: 'https://other-host.example.test', apiKey: 'key-for-b' }); + }); +});