From 419204d587645538849e9517a925f15edff85a79 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Mon, 18 May 2026 21:13:07 +0200 Subject: [PATCH] fix(download-password): revoke url after 60 secs --- .../auth/routes/download-recovery-key.tsx | 11 +-- .../modules/settings/routes/settings.tsx | 67 ++++++++++++++----- .../__tests__/system.controller.test.ts | 35 +++++++++- 3 files changed, 91 insertions(+), 22 deletions(-) diff --git a/app/client/modules/auth/routes/download-recovery-key.tsx b/app/client/modules/auth/routes/download-recovery-key.tsx index 612eaae1..6ca7f0c7 100644 --- a/app/client/modules/auth/routes/download-recovery-key.tsx +++ b/app/client/modules/auth/routes/download-recovery-key.tsx @@ -25,7 +25,7 @@ export function DownloadRecoveryKeyPage() { document.body.appendChild(a); a.click(); document.body.removeChild(a); - window.URL.revokeObjectURL(url); + window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000); toast.success("Recovery key downloaded successfully!"); void navigate({ to: "/volumes", replace: true }); @@ -59,8 +59,9 @@ export function DownloadRecoveryKeyPage() { Important: Save This File Securely - Your Restic password is essential for recovering your backup data. If you lose access to this server without - this file, your backups will be unrecoverable. Store it in a password manager or encrypted storage. + Your Restic password is essential for recovering your backup data. If you lose access to this server + without this file, your backups will be unrecoverable. Store it in a password manager or encrypted + storage. @@ -76,7 +77,9 @@ export function DownloadRecoveryKeyPage() { required disabled={downloadResticPassword.isPending} /> -

Enter your account password to download the recovery key

+

+ Enter your account password to download the recovery key +

diff --git a/app/client/modules/settings/routes/settings.tsx b/app/client/modules/settings/routes/settings.tsx index dfe3c5be..660a35ab 100644 --- a/app/client/modules/settings/routes/settings.tsx +++ b/app/client/modules/settings/routes/settings.tsx @@ -85,7 +85,7 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i document.body.appendChild(a); a.click(); document.body.removeChild(a); - window.URL.revokeObjectURL(url); + window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000); toast.success("Restic password file downloaded successfully"); setDownloadDialogOpen(false); @@ -213,11 +213,22 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
- +
- +
@@ -237,7 +248,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i void handleTimeFormatChange(value as TimeFormatPreference)} + onValueChange={(value) => + void handleTimeFormatChange(value as TimeFormatPreference) + } > @@ -270,7 +285,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
-

Preview: {formatDateTime(new Date())}

+

+ Preview: {formatDateTime(new Date())} +

@@ -279,7 +296,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i Change Password - Update your password to keep your account secure + + Update your password to keep your account secure +
@@ -305,7 +324,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i required minLength={8} /> -

Must be at least 8 characters long

+

+ Must be at least 8 characters long +

@@ -331,13 +352,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i Backup Recovery Key - Download your recovery key for Restic backups + + Download your recovery key for Restic backups +

- This file contains the encryption password used by Restic to secure your backups. Store it in a safe - place (like a password manager or encrypted storage). If you lose access to this server, you'll need - this file to recover your backup data. + This file contains the encryption password used by Restic to secure your backups. + Store it in a safe place (like a password manager or encrypted storage). If you lose + access to this server, you'll need this file to recover your backup data.

@@ -352,7 +375,8 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i Download Recovery Key - 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.
@@ -402,7 +426,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i Organization Details - Reference details for the active organization + + Reference details for the active organization +
@@ -421,7 +447,9 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i Members - Manage organization members and roles + + Manage organization members and roles + @@ -434,10 +462,15 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i Single Sign-On - Configure OIDC provider settings + + Configure OIDC provider settings + - + diff --git a/app/server/modules/system/__tests__/system.controller.test.ts b/app/server/modules/system/__tests__/system.controller.test.ts index 989e250f..cc36475e 100644 --- a/app/server/modules/system/__tests__/system.controller.test.ts +++ b/app/server/modules/system/__tests__/system.controller.test.ts @@ -3,6 +3,10 @@ import { createApp } from "~/server/app"; import { createTestSession, createTestSessionWithGlobalAdmin, getAuthHeaders } from "~/test/helpers/auth"; import { systemService } from "../system.service"; 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(); @@ -123,6 +127,35 @@ describe("system security", () => { }); describe("input validation", () => { + test("should return complete decrypted restic password content", async () => { + const { cryptoUtils: actualCryptoUtils } = + await vi.importActual("~/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 () => { const res = await app.request("/api/v1/system/restic-password", { method: "POST", @@ -137,7 +170,7 @@ describe("system security", () => { }); 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", { method: "POST",