fix(download-password): revoke url after 60 secs

This commit is contained in:
Nicolas Meienberger 2026-05-18 21:13:07 +02:00
parent 11dacd7c71
commit 419204d587
No known key found for this signature in database
3 changed files with 91 additions and 22 deletions

View file

@ -25,7 +25,7 @@ export function DownloadRecoveryKeyPage() {
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
window.URL.revokeObjectURL(url); window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
toast.success("Recovery key downloaded successfully!"); toast.success("Recovery key downloaded successfully!");
void navigate({ to: "/volumes", replace: true }); void navigate({ to: "/volumes", replace: true });
@ -59,8 +59,9 @@ export function DownloadRecoveryKeyPage() {
<AlertTriangle className="size-5" /> <AlertTriangle className="size-5" />
<AlertTitle>Important: Save This File Securely</AlertTitle> <AlertTitle>Important: Save This File Securely</AlertTitle>
<AlertDescription> <AlertDescription>
Your Restic password is essential for recovering your backup data. If you lose access to this server without Your Restic password is essential for recovering your backup data. If you lose access to this server
this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage. without this file, your backups will be unrecoverable. Store it in a password manager or encrypted
storage.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@ -76,7 +77,9 @@ export function DownloadRecoveryKeyPage() {
required required
disabled={downloadResticPassword.isPending} disabled={downloadResticPassword.isPending}
/> />
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p> <p className="text-xs text-muted-foreground">
Enter your account password to download the recovery key
</p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View file

@ -85,7 +85,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
window.URL.revokeObjectURL(url); window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000);
toast.success("Restic password file downloaded successfully"); toast.success("Restic password file downloaded successfully");
setDownloadDialogOpen(false); setDownloadDialogOpen(false);
@ -213,11 +213,22 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="username">Username</Label> <Label htmlFor="username">Username</Label>
<Input id="username" value={appContext.user?.username} disabled className="max-w-md" /> <Input
id="username"
value={appContext.user?.username}
disabled
className="max-w-md"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email">Email</Label> <Label htmlFor="email">Email</Label>
<Input id="email" type="email" value={appContext.user?.email} disabled className="max-w-md" /> <Input
id="email"
type="email"
value={appContext.user?.email}
disabled
className="max-w-md"
/>
</div> </div>
</CardContent> </CardContent>
@ -237,7 +248,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Label htmlFor="date-format">Date format</Label> <Label htmlFor="date-format">Date format</Label>
<Select <Select
value={dateFormat} value={dateFormat}
onValueChange={(value) => void handleDateFormatChange(value as DateFormatPreference)} onValueChange={(value) =>
void handleDateFormatChange(value as DateFormatPreference)
}
> >
<SelectTrigger id="date-format"> <SelectTrigger id="date-format">
<SelectValue /> <SelectValue />
@ -255,7 +268,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Label htmlFor="time-format">Time format</Label> <Label htmlFor="time-format">Time format</Label>
<Select <Select
value={timeFormat} value={timeFormat}
onValueChange={(value) => void handleTimeFormatChange(value as TimeFormatPreference)} onValueChange={(value) =>
void handleTimeFormatChange(value as TimeFormatPreference)
}
> >
<SelectTrigger id="time-format"> <SelectTrigger id="time-format">
<SelectValue /> <SelectValue />
@ -270,7 +285,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</Select> </Select>
</div> </div>
</div> </div>
<p className="text-sm text-muted-foreground">Preview: {formatDateTime(new Date())}</p> <p className="text-sm text-muted-foreground">
Preview: {formatDateTime(new Date())}
</p>
</div> </div>
</CardContent> </CardContent>
@ -279,7 +296,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<KeyRound className="size-5" /> <KeyRound className="size-5" />
Change Password Change Password
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Update your password to keep your account secure</CardDescription> <CardDescription className="mt-1.5">
Update your password to keep your account secure
</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className="p-6">
<form onSubmit={handleChangePassword} className="space-y-4"> <form onSubmit={handleChangePassword} className="space-y-4">
@ -305,7 +324,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
required required
minLength={8} minLength={8}
/> />
<p className="text-xs text-muted-foreground">Must be at least 8 characters long</p> <p className="text-xs text-muted-foreground">
Must be at least 8 characters long
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label> <Label htmlFor="confirm-password">Confirm New Password</Label>
@ -331,13 +352,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Download className="size-5" /> <Download className="size-5" />
Backup Recovery Key Backup Recovery Key
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Download your recovery key for Restic backups</CardDescription> <CardDescription className="mt-1.5">
Download your recovery key for Restic backups
</CardDescription>
</div> </div>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<p className="text-sm text-muted-foreground max-w-2xl"> <p className="text-sm text-muted-foreground max-w-2xl">
This file contains the encryption password used by Restic to secure your backups. Store it in a safe This file contains the encryption password used by Restic to secure your backups.
place (like a password manager or encrypted storage). If you lose access to this server, you'll need Store it in a safe place (like a password manager or encrypted storage). If you lose
this file to recover your backup data. access to this server, you'll need this file to recover your backup data.
</p> </p>
<Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}> <Dialog open={downloadDialogOpen} onOpenChange={setDownloadDialogOpen}>
@ -352,7 +375,8 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<DialogHeader> <DialogHeader>
<DialogTitle>Download Recovery Key</DialogTitle> <DialogTitle>Download Recovery Key</DialogTitle>
<DialogDescription> <DialogDescription>
For security reasons, please enter your account password to download the recovery key file. For security reasons, please enter your account password to download
the recovery key file.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
@ -402,7 +426,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Fingerprint className="size-5" /> <Fingerprint className="size-5" />
Organization Details Organization Details
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Reference details for the active organization</CardDescription> <CardDescription className="mt-1.5">
Reference details for the active organization
</CardDescription>
</div> </div>
<CardContent className="p-6 space-y-2"> <CardContent className="p-6 space-y-2">
<Label htmlFor="organization-id">Organization ID</Label> <Label htmlFor="organization-id">Organization ID</Label>
@ -421,7 +447,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<Building2 className="size-5" /> <Building2 className="size-5" />
Members Members
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Manage organization members and roles</CardDescription> <CardDescription className="mt-1.5">
Manage organization members and roles
</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className="p-6">
<OrgMembersSection initialMembers={initialMembers} /> <OrgMembersSection initialMembers={initialMembers} />
@ -434,10 +462,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
<SettingsIcon className="size-5" /> <SettingsIcon className="size-5" />
Single Sign-On Single Sign-On
</CardTitle> </CardTitle>
<CardDescription className="mt-1.5">Configure OIDC provider settings</CardDescription> <CardDescription className="mt-1.5">
Configure OIDC provider settings
</CardDescription>
</div> </div>
<CardContent className="p-6"> <CardContent className="p-6">
<SsoSettingsSection initialSettings={initialSsoSettings} initialOrigin={initialOrigin} /> <SsoSettingsSection
initialSettings={initialSsoSettings}
initialOrigin={initialOrigin}
/>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>

View file

@ -3,6 +3,10 @@ import { createApp } from "~/server/app";
import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth"; import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth";
import { systemService } from "../system.service"; import { systemService } from "../system.service";
import * as authHelpers from "~/server/modules/auth/helpers"; import * as authHelpers from "~/server/modules/auth/helpers";
import { db } from "~/server/db/db";
import { organization } from "~/server/db/schema";
import { eq } from "drizzle-orm";
import { cryptoUtils } from "~/server/utils/crypto";
const app = createApp(); const app = createApp();
@ -123,6 +127,35 @@ describe("system security", () => {
}); });
describe("input validation", () => { describe("input validation", () => {
test("should return complete decrypted restic password content", async () => {
const { cryptoUtils: actualCryptoUtils } =
await vi.importActual<typeof import("~/server/utils/crypto")>("~/server/utils/crypto");
const resticPassword = "correct-restic-passwordb";
const encryptedResticPassword = await actualCryptoUtils.sealSecret(resticPassword);
await db
.update(organization)
.set({ metadata: { resticPassword: encryptedResticPassword } })
.where(eq(organization.id, session.organizationId));
vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValueOnce(true);
vi.spyOn(cryptoUtils, "resolveSecret").mockImplementationOnce(actualCryptoUtils.resolveSecret);
const res = await app.request("/api/v1/system/restic-password", {
method: "POST",
headers: {
...session.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({
password: "password",
}),
});
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/plain");
expect(await res.text()).toBe(resticPassword);
});
test("should return 400 for invalid payload on restic-password", async () => { test("should return 400 for invalid payload on restic-password", async () => {
const res = await app.request("/api/v1/system/restic-password", { const res = await app.request("/api/v1/system/restic-password", {
method: "POST", method: "POST",
@ -137,7 +170,7 @@ describe("system security", () => {
}); });
test("should return 401 for incorrect password on restic-password", async () => { test("should return 401 for incorrect password on restic-password", async () => {
vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValue(false); vi.spyOn(authHelpers, "verifyUserPassword").mockResolvedValueOnce(false);
const res = await app.request("/api/v1/system/restic-password", { const res = await app.request("/api/v1/system/restic-password", {
method: "POST", method: "POST",