fix(auth): the Subsonic password is generated, never the login password (M462 #5026)
release / govulncheck (push) Successful in 39s
release / web (push) Successful in 1m8s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m37s
release / android (push) Successful in 5m56s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped

`minstrel admin reset-password` copied the new login password into
subsonic_password, which is stored in plain text because Subsonic t/s
sign-in needs it. Every account recovered through the CLI had its login
password readable in the database, and changing the password later left
the copy behind.

- reset-password now changes only password_hash.
- Migration 0064 clears every subsonic_password, removing the copies.
- Settings gets a Subsonic password card: the server generates a random
  password, shows it once, and it can be regenerated or turned off
  (GET/POST/DELETE /api/me/subsonic-password, audited). Generated rather
  than user-chosen so it can never be a reused password.
- docs/security.md describes the separate password instead of the known
  issue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:54:30 -04:00
co-authored by Claude Opus 5.5
parent 522503e011
commit edd9a3a6db
14 changed files with 456 additions and 31 deletions
+19
View File
@@ -55,6 +55,25 @@ export async function regenerateAPIToken(): Promise<APITokenResponse> {
return api.post<APITokenResponse>('/api/me/api-token', {});
}
// Subsonic password (#5026) ------------------------------------------------
// For Subsonic clients that only sign in with a username and password (the
// t/s scheme). The server generates it, so it is never the login password;
// like the API key it is shown once, when generated.
export type SubsonicPasswordStatus = { enabled: boolean };
export async function getSubsonicPasswordStatus(): Promise<SubsonicPasswordStatus> {
return api.get<SubsonicPasswordStatus>('/api/me/subsonic-password');
}
export async function generateSubsonicPassword(): Promise<{ password: string }> {
return api.post<{ password: string }>('/api/me/subsonic-password', {});
}
export async function clearSubsonicPassword(): Promise<void> {
await api.del('/api/me/subsonic-password');
}
// Submits the browser's current IANA timezone for the authenticated
// user. Called from the auth store on login + bootstrap + once weekly
// (cadence tracked client-side in localStorage). Failures are
+121 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { pageTitle } from '$lib/branding';
import { useQueryClient } from '@tanstack/svelte-query';
import type { CreateQueryResult, CreateMutationResult } from '@tanstack/svelte-query';
@@ -19,7 +20,10 @@
import {
updateProfile,
changePassword,
regenerateAPIToken
regenerateAPIToken,
getSubsonicPasswordStatus,
generateSubsonicPassword,
clearSubsonicPassword
} from '$lib/api/me';
import { errCode } from '$lib/api/errors';
import { pushToast } from '$lib/stores/toast.svelte';
@@ -203,6 +207,77 @@
tokenSaving = false;
}
}
// Subsonic password card (#5026) ------------------------------------------
// null until the status loads. The value itself is only held right after
// Generate, for the same reason as the API token above.
let subsonicEnabled = $state<boolean | null>(null);
let subsonicPassword = $state<string | null>(null);
let subsonicSaving = $state(false);
let confirmSubsonic = $state<'generate' | 'clear' | null>(null);
let subsonicTimer: ReturnType<typeof setTimeout> | undefined;
onMount(async () => {
try {
subsonicEnabled = (await getSubsonicPasswordStatus()).enabled;
} catch {
// Leave it unknown; the buttons still work.
}
});
// Replacing or removing a password breaks clients already using it, so
// both ask for a second click. A first-time Generate breaks nothing.
function armSubsonic(action: 'generate' | 'clear'): boolean {
if (confirmSubsonic === action) {
if (subsonicTimer) clearTimeout(subsonicTimer);
confirmSubsonic = null;
return true;
}
confirmSubsonic = action;
if (subsonicTimer) clearTimeout(subsonicTimer);
subsonicTimer = setTimeout(() => { confirmSubsonic = null; }, 5000);
return false;
}
async function onGenerateSubsonic() {
if (subsonicEnabled && !armSubsonic('generate')) return;
subsonicSaving = true;
try {
subsonicPassword = (await generateSubsonicPassword()).password;
subsonicEnabled = true;
pushToast('Subsonic password created. Copy it now; it will not be shown again.');
} catch (e: unknown) {
pushToast(`Generate failed: ${errCode(e)}`, 'error');
} finally {
subsonicSaving = false;
}
}
async function onClearSubsonic() {
if (!armSubsonic('clear')) return;
subsonicSaving = true;
try {
await clearSubsonicPassword();
subsonicPassword = null;
subsonicEnabled = false;
pushToast('Subsonic password turned off.');
} catch (e: unknown) {
pushToast(`Turn off failed: ${errCode(e)}`, 'error');
} finally {
subsonicSaving = false;
}
}
async function copySubsonic() {
if (!subsonicPassword) return;
try {
await navigator.clipboard.writeText(subsonicPassword);
pushToast('Password copied to clipboard.');
} catch {
pushToast('Copy failed.', 'error');
}
}
</script>
<svelte:head><title>{pageTitle('Settings')}</title></svelte:head>
@@ -553,9 +628,51 @@
</div>
</section>
<!-- Sits with Password and API Token rather than near the bottom: these
three are the account-security group, and this is the one that tells
you the other two need attention. -->
<!-- Subsonic password card -->
<section class="space-y-3 rounded border border-border bg-surface p-4">
<h2 class="text-lg font-semibold">Subsonic password</h2>
<p class="text-sm text-text-secondary">
For Subsonic apps that can't use an API token and ask for a username and password instead.
Sign those apps in with your username and this password, not your login password.
Minstrel generates it, and has to store it readable for these apps to work, so it is never
your login password. Use the API token where the app supports it.
</p>
{#if subsonicEnabled !== null}
<p class="text-sm">
{subsonicEnabled ? 'A Subsonic password is set.' : "No Subsonic password is set; these apps can't sign in."}
</p>
{/if}
{#if subsonicPassword}
<code class="block break-all rounded bg-background p-2 text-xs">
{subsonicPassword}
</code>
<p class="text-xs text-text-secondary">Copy this now. It won't be shown again.</p>
{/if}
<div class="flex gap-2">
{#if subsonicPassword}
<button type="button" onclick={copySubsonic}
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
Copy
</button>
{/if}
<button type="button" disabled={subsonicSaving}
onclick={onGenerateSubsonic}
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{confirmSubsonic === 'generate' ? 'Click again to confirm' : (subsonicEnabled ? 'Regenerate' : 'Generate')}
</button>
{#if subsonicEnabled}
<button type="button" disabled={subsonicSaving}
onclick={onClearSubsonic}
class="inline-flex items-center rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{confirmSubsonic === 'clear' ? 'Click again to confirm' : 'Turn off'}
</button>
{/if}
</div>
</section>
<!-- Sits with Password, API Token and Subsonic password rather than near
the bottom: these are the account-security group, and this is the one
that tells you the others need attention. -->
<ActiveSessions />
<section class="space-y-3 rounded border border-border bg-surface p-4">
+54 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { readable, writable } from 'svelte/store';
import type { LBStatus } from '$lib/api/listenbrainz';
@@ -16,7 +16,10 @@ vi.mock('$lib/api/me', () => ({
changePassword: vi.fn(),
// Default to a resolved value so the page's $effect doesn't crash
// on `.then()` of undefined when individual tests don't override.
regenerateAPIToken: vi.fn()
regenerateAPIToken: vi.fn(),
getSubsonicPasswordStatus: vi.fn(),
generateSubsonicPassword: vi.fn(),
clearSubsonicPassword: vi.fn()
}));
// Mutable holder so individual tests can inject populated metrics;
@@ -45,7 +48,10 @@ import {
import {
updateProfile,
changePassword,
regenerateAPIToken
regenerateAPIToken,
getSubsonicPasswordStatus,
generateSubsonicPassword,
clearSubsonicPassword
} from '$lib/api/me';
function mockStatusStore(data: LBStatus) {
@@ -361,3 +367,48 @@ describe('Settings page — API Token card', () => {
expect(screen.queryByRole('button', { name: /^copy$/i })).toBeNull();
});
});
describe('Settings page — Subsonic password card', () => {
function mockLB() {
(createLBStatusQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockStatusStore({ enabled: false, token_set: false, last_scrobbled_at: null })
);
(createTokenMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
(createEnabledMutation as ReturnType<typeof vi.fn>).mockReturnValue(mockMutationStore());
}
test('with none set, Generate creates one on the first click and shows it once', async () => {
mockLB();
(getSubsonicPasswordStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ enabled: false });
(generateSubsonicPassword as ReturnType<typeof vi.fn>).mockResolvedValue({ password: 'gen_pw_123' });
render(SettingsPage);
await waitFor(() => expect(screen.getByText(/no subsonic password is set/i)).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /turn off/i })).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: /^generate$/i }));
await waitFor(() => expect(generateSubsonicPassword).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByText('gen_pw_123')).toBeInTheDocument());
expect(screen.getByText(/a subsonic password is set/i)).toBeInTheDocument();
});
test('with one set, Regenerate and Turn off each need a second click', async () => {
mockLB();
(getSubsonicPasswordStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ enabled: true });
(clearSubsonicPassword as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
render(SettingsPage);
// The API token card has a Regenerate button too; the Subsonic card's is
// the one beside Turn off.
const turnOff = await screen.findByRole('button', { name: /turn off/i });
const subsonicRegen = within(turnOff.parentElement!).getByRole('button', { name: /^regenerate$/i });
await fireEvent.click(subsonicRegen);
expect(generateSubsonicPassword).not.toHaveBeenCalled();
expect(subsonicRegen).toHaveTextContent(/click again to confirm/i);
await fireEvent.click(turnOff);
expect(clearSubsonicPassword).not.toHaveBeenCalled();
await fireEvent.click(turnOff);
await waitFor(() => expect(clearSubsonicPassword).toHaveBeenCalledTimes(1));
await waitFor(() => expect(screen.getByText(/no subsonic password is set/i)).toBeInTheDocument());
});
});