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' }); + }); +});