feat: safer backups

This commit is contained in:
Vasily Zubarev
2025-04-04 14:52:48 +02:00
parent 1b1d72b22d
commit 48cb9c50cb
5 changed files with 18 additions and 11 deletions

View File

@@ -10,6 +10,7 @@ import path from "path"
const SUPPORTED_BACKUP_VERSIONS = ["1.0"] const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
const REMOVE_EXISTING_DATA = true const REMOVE_EXISTING_DATA = true
const MAX_BACKUP_SIZE = 256 * 1024 * 1024 // 256MB
export async function restoreBackupAction(prevState: any, formData: FormData) { export async function restoreBackupAction(prevState: any, formData: FormData) {
const user = await getCurrentUser() const user = await getCurrentUser()
@@ -20,6 +21,10 @@ export async function restoreBackupAction(prevState: any, formData: FormData) {
return { success: false, error: "No file provided" } return { success: false, error: "No file provided" }
} }
if (file.size > MAX_BACKUP_SIZE) {
return { success: false, error: `Backup file too large. Maximum size is ${MAX_BACKUP_SIZE / 1024 / 1024}MB` }
}
// Read zip archive // Read zip archive
let zip: JSZip let zip: JSZip
try { try {
@@ -88,7 +93,7 @@ export async function restoreBackupAction(prevState: any, formData: FormData) {
const userUploadsDirectory = await getUserUploadsDirectory(user) const userUploadsDirectory = await getUserUploadsDirectory(user)
for (const file of files) { for (const file of files) {
const filePathWithoutPrefix = file.path.replace(/^.*\/uploads\//, "") const filePathWithoutPrefix = path.normalize(file.path.replace(/^.*\/uploads\//, ""))
const zipFilePath = path.join("data/uploads", filePathWithoutPrefix) const zipFilePath = path.join("data/uploads", filePathWithoutPrefix)
const zipFile = zip.file(zipFilePath) const zipFile = zip.file(zipFilePath)
if (!zipFile) { if (!zipFile) {
@@ -96,12 +101,16 @@ export async function restoreBackupAction(prevState: any, formData: FormData) {
continue continue
} }
const fileContents = await zipFile.async("nodebuffer")
const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix) const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
const fileContent = await zipFile.async("nodebuffer") if (!fullFilePath.startsWith(path.normalize(userUploadsDirectory))) {
console.error(`Attempted path traversal detected for file ${file.path}`)
continue
}
try { try {
await fs.mkdir(path.dirname(fullFilePath), { recursive: true }) await fs.mkdir(path.dirname(fullFilePath), { recursive: true })
await fs.writeFile(fullFilePath, fileContent) await fs.writeFile(fullFilePath, fileContents)
restoredFilesCount++ restoredFilesCount++
} catch (error) { } catch (error) {
console.error(`Error writing file ${fullFilePath}:`, error) console.error(`Error writing file ${fullFilePath}:`, error)

View File

@@ -81,8 +81,8 @@ export async function saveFileAsTransactionAction(prevState: any, formData: Form
const newRelativeFilePath = await getTransactionFileUploadPath(file.id, originalFileName, transaction) const newRelativeFilePath = await getTransactionFileUploadPath(file.id, originalFileName, transaction)
// Move file to new location and name // Move file to new location and name
const oldFullFilePath = path.join(userUploadsDirectory, file.path) const oldFullFilePath = path.join(userUploadsDirectory, path.normalize(file.path))
const newFullFilePath = path.join(userUploadsDirectory, newRelativeFilePath) const newFullFilePath = path.join(userUploadsDirectory, path.normalize(newRelativeFilePath))
await mkdir(path.dirname(newFullFilePath), { recursive: true }) await mkdir(path.dirname(newFullFilePath), { recursive: true })
await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath)) await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath))

View File

@@ -78,9 +78,7 @@ export const FormConvertCurrency = ({
async function getCurrencyRate(currencyCodeFrom: string, currencyCodeTo: string, date: Date): Promise<number> { async function getCurrencyRate(currencyCodeFrom: string, currencyCodeTo: string, date: Date): Promise<number> {
try { try {
const formattedDate = format(date, "yyyy-MM-dd") const formattedDate = format(date, "yyyy-MM-dd")
const url = `/api/currency?from=${currencyCodeFrom}&to=${currencyCodeTo}&date=${formattedDate}` const response = await fetch(`/api/currency?from=${currencyCodeFrom}&to=${currencyCodeTo}&date=${formattedDate}`)
const response = await fetch(url)
if (!response.ok) { if (!response.ok) {
const errorData = await response.json() const errorData = await response.json()

View File

@@ -32,7 +32,7 @@ export async function getTransactionFileUploadPath(fileUuid: string, filename: s
export async function fullPathForFile(user: User, file: File) { export async function fullPathForFile(user: User, file: File) {
const userUploadsDirectory = await getUserUploadsDirectory(user) const userUploadsDirectory = await getUserUploadsDirectory(user)
return path.join(userUploadsDirectory, file.path) return path.join(userUploadsDirectory, path.normalize(file.path))
} }
function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") { function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") {
@@ -46,7 +46,7 @@ function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{nam
export async function fileExists(filePath: string) { export async function fileExists(filePath: string) {
try { try {
await access(filePath, constants.F_OK) await access(path.normalize(filePath), constants.F_OK)
return true return true
} catch { } catch {
return false return false

View File

@@ -74,7 +74,7 @@ export const deleteFile = async (id: string, userId: string) => {
} }
try { try {
await unlink(path.resolve(file.path)) await unlink(path.resolve(path.normalize(file.path)))
} catch (error) { } catch (error) {
console.error("Error deleting file:", error) console.error("Error deleting file:", error)
} }