Security fix for path-traversal attack (#48)

Additionally:

- Make expense and budget modal "reset" once closed, saved, or deleted.
- Make manifest icons dark
- Budgets in small screens should be full-screen
- Minor code cleanup

Fixes #48
This commit is contained in:
Bruno Bernardino
2025-02-27 15:02:10 +00:00
parent b3bd8cb3cc
commit 4faa7bd05d
10 changed files with 246 additions and 30 deletions

View File

@@ -9,26 +9,33 @@ interface BudgetModalProps {
onClickSave: (newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) => Promise<void>;
onClickDelete: () => Promise<void>;
onClose: () => void;
shouldResetForm: boolean;
}
export default function BudgetModal(
{ isOpen, budget, onClickSave, onClickDelete, onClose }: BudgetModalProps,
{ isOpen, budget, onClickSave, onClickDelete, onClose, shouldResetForm }: BudgetModalProps,
) {
const newBudgetName = useSignal<string>(budget?.name ?? '');
const newBudgetMonth = useSignal<string>(budget?.month ?? new Date().toISOString().substring(0, 10));
const newBudgetValue = useSignal<number>(budget?.value ?? 100);
const resetForm = () => {
newBudgetName.value = '';
newBudgetMonth.value = new Date().toISOString().substring(0, 10);
newBudgetValue.value = 100;
};
useEffect(() => {
if (budget) {
newBudgetName.value = budget.name;
newBudgetMonth.value = `${budget.month}-15`;
newBudgetValue.value = budget.value;
} else {
newBudgetName.value = '';
newBudgetMonth.value = new Date().toISOString().substring(0, 10);
newBudgetValue.value = 100;
}
}, [budget]);
if (shouldResetForm) {
resetForm();
}
}, [budget, shouldResetForm]);
return (
<>

View File

@@ -21,10 +21,11 @@ interface ExpenseModalProps {
) => Promise<void>;
onClickDelete: () => Promise<void>;
onClose: () => void;
shouldResetForm: boolean;
}
export default function ExpenseModal(
{ isOpen, expense, budgets, onClickSave, onClickDelete, onClose }: ExpenseModalProps,
{ isOpen, expense, budgets, onClickSave, onClickDelete, onClose, shouldResetForm }: ExpenseModalProps,
) {
const newExpenseCost = useSignal<number | ''>(expense?.cost ?? '');
const newExpenseDescription = useSignal<string>(expense?.description ?? '');
@@ -34,6 +35,14 @@ export default function ExpenseModal(
const suggestions = useSignal<string[]>([]);
const showSuggestions = useSignal<boolean>(false);
const resetForm = () => {
newExpenseCost.value = '';
newExpenseDescription.value = '';
newExpenseBudget.value = 'Misc';
newExpenseDate.value = '';
newExpenseIsRecurring.value = false;
};
useEffect(() => {
if (expense) {
newExpenseCost.value = expense.cost;
@@ -42,15 +51,12 @@ export default function ExpenseModal(
newExpenseDate.value = expense.date;
newExpenseIsRecurring.value = expense.is_recurring;
showSuggestions.value = false;
} else {
newExpenseCost.value = '';
newExpenseDescription.value = '';
newExpenseBudget.value = 'Misc';
newExpenseDate.value = '';
newExpenseIsRecurring.value = false;
showSuggestions.value = false;
}
}, [expense]);
if (shouldResetForm) {
resetForm();
}
}, [expense, shouldResetForm]);
const sortedBudgetNames = budgets.map((budget) => budget.name).sort();
@@ -225,14 +231,15 @@ export default function ExpenseModal(
: null}
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
onClick={() =>
onClick={() => {
onClickSave(
newExpenseCost.value as number,
newExpenseDescription.value,
newExpenseBudget.value,
newExpenseDate.value,
newExpenseIsRecurring.value,
)}
);
}}
>
{expense ? 'Update' : 'Create'}
</button>

View File

@@ -102,7 +102,7 @@ export default function ListBudgets(
return (
<div
onClick={() => budget.id === 'total' ? swapView('chart') : onClickEditBudget(budget.id)}
class='flex max-w-sm gap-y-4 gap-x-4 rounded shadow-md bg-slate-700 relative cursor-pointer py-4 px-6 hover:opacity-80'
class='flex w-full md:w-auto max-w-sm gap-y-4 gap-x-4 rounded shadow-md bg-slate-700 relative cursor-pointer py-4 px-6 hover:opacity-80'
>
<article class='order-first tracking-tight flex flex-col text-base mr-4'>
<span class='font-bold text-lg' title='Amount used from budgeted amount'>

View File

@@ -59,6 +59,8 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
const editingExpense = useSignal<Expense | null>(null);
const isBudgetModalOpen = useSignal<boolean>(false);
const editingBudget = useSignal<Budget | null>(null);
const shouldResetExpenseModal = useSignal<boolean>(false);
const shouldResetBudgetModal = useSignal<boolean>(false);
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' });
@@ -197,6 +199,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
return;
}
shouldResetExpenseModal.value = false;
editingExpense.value = null;
isExpenseModalOpen.value = true;
}
@@ -209,6 +212,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
return;
}
shouldResetBudgetModal.value = false;
editingBudget.value = null;
isBudgetModalOpen.value = true;
}
@@ -221,6 +225,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
return;
}
shouldResetExpenseModal.value = false;
editingExpense.value = expenses.value.find((expense) => expense.id === expenseId)!;
isExpenseModalOpen.value = true;
}
@@ -238,6 +243,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
return;
}
shouldResetBudgetModal.value = false;
editingBudget.value = budgets.value.find((budget) => budget.id === budgetId)!;
isBudgetModalOpen.value = true;
}
@@ -292,6 +298,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
isExpenseModalOpen.value = false;
editingExpense.value = null;
shouldResetExpenseModal.value = true;
} catch (error) {
console.error(error);
alert(error);
@@ -326,6 +333,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
budgets.value = [...result.newBudgets];
isExpenseModalOpen.value = false;
shouldResetExpenseModal.value = true;
} catch (error) {
console.error(error);
alert(error);
@@ -379,6 +387,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
isBudgetModalOpen.value = false;
editingBudget.value = null;
shouldResetBudgetModal.value = true;
} catch (error) {
console.error(error);
alert(error);
@@ -410,6 +419,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
budgets.value = [...result.newBudgets];
isBudgetModalOpen.value = false;
shouldResetBudgetModal.value = true;
} catch (error) {
console.error(error);
alert(error);
@@ -460,6 +470,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
isExpenseModalOpen.value = false;
editingExpense.value = null;
shouldResetExpenseModal.value = true;
} catch (error) {
console.error(error);
alert(error);
@@ -508,6 +519,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
isBudgetModalOpen.value = false;
editingBudget.value = null;
shouldResetBudgetModal.value = true;
} catch (error) {
console.error(error);
alert(error);
@@ -519,11 +531,13 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
function onCloseExpense() {
isExpenseModalOpen.value = false;
editingExpense.value = null;
shouldResetExpenseModal.value = true;
}
function onCloseBudget() {
isBudgetModalOpen.value = false;
editingBudget.value = null;
shouldResetBudgetModal.value = true;
}
function toggleNewOptionsDropdown() {
@@ -780,6 +794,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
onClickSave={onClickSaveExpense}
onClickDelete={onClickDeleteExpense}
onClose={onCloseExpense}
shouldResetForm={shouldResetExpenseModal.value}
/>
<BudgetModal
@@ -788,6 +803,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
onClickSave={onClickSaveBudget}
onClickDelete={onClickDeleteBudget}
onClose={onCloseBudget}
shouldResetForm={shouldResetBudgetModal.value}
/>
</>
);

View File

@@ -195,13 +195,12 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
isExporting.value = true;
const fileName = ['feeds-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.opml']
.join('');
const fileName = `feeds-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.opml`;
const exportContents = formatNewsFeedsToOpml([...feeds.peek()]);
// Add content-type
const xmlContent = ['data:application/xml; charset=utf-8,', exportContents].join('');
const xmlContent = `data:application/xml; charset=utf-8,${exportContents}`;
// Download the file
const data = encodeURI(xmlContent);

View File

@@ -1,11 +1,33 @@
import { join } from 'std/path/join.ts';
import { resolve } from 'std/path/resolve.ts';
import { lookup } from 'mrmime';
import { getFilesRootPath } from '/lib/config.ts';
import { Directory, DirectoryFile } from '/lib/types.ts';
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
/**
* Ensures the user path is valid and securely accessible (meaning it's not trying to access files outside of the user's root directory).
* Does not check if the path exists.
*
* @param userId - The user ID
* @param path - The relative path (user-provided) to check
*/
export function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path: string): void {
const userRootPath = join(getFilesRootPath(), userId, '/');
const fullPath = join(userRootPath, path);
const resolvedFullPath = `${resolve(fullPath)}/`;
if (!resolvedFullPath.startsWith(userRootPath)) {
throw new Error('Invalid file path');
}
}
export async function getDirectories(userId: string, path: string): Promise<Directory[]> {
ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(getFilesRootPath(), userId, path);
const directories: Directory[] = [];
@@ -34,6 +56,8 @@ export async function getDirectories(userId: string, path: string): Promise<Dire
}
export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> {
ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(getFilesRootPath(), userId, path);
const files: DirectoryFile[] = [];
@@ -62,6 +86,8 @@ export async function getFiles(userId: string, path: string): Promise<DirectoryF
}
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
ensureUserPathIsValidAndSecurelyAccessible(userId, path);
const rootPath = join(getFilesRootPath(), userId, path);
// Ensure the user directory exists
@@ -98,6 +124,8 @@ async function getPathEntries(userId: string, path: string): Promise<Deno.DirEnt
}
export async function createDirectory(userId: string, path: string, name: string): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
const rootPath = join(getFilesRootPath(), userId, path);
try {
@@ -117,6 +145,9 @@ export async function renameDirectoryOrFile(
oldName: string,
newName: string,
): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(oldPath, oldName));
ensureUserPathIsValidAndSecurelyAccessible(userId, join(newPath, newName));
const oldRootPath = join(getFilesRootPath(), userId, oldPath);
const newRootPath = join(getFilesRootPath(), userId, newPath);
@@ -131,6 +162,8 @@ export async function renameDirectoryOrFile(
}
export async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
const rootPath = join(getFilesRootPath(), userId, path);
try {
@@ -154,6 +187,8 @@ export async function createFile(
name: string,
contents: string | ArrayBuffer,
): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
const rootPath = join(getFilesRootPath(), userId, path);
try {
@@ -176,6 +211,8 @@ export async function updateFile(
name: string,
contents: string,
): Promise<boolean> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
const rootPath = join(getFilesRootPath(), userId, path);
try {
@@ -193,6 +230,8 @@ export async function getFile(
path: string,
name?: string,
): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string; byteSize?: number }> {
ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name || ''));
const rootPath = join(getFilesRootPath(), userId, path);
try {

View File

@@ -11,7 +11,7 @@ import {
getProperDestinationPath,
getPropertyNames,
} from '/lib/utils/webdav.ts';
import { getFile } from '/lib/data/files.ts';
import { ensureUserPathIsValidAndSecurelyAccessible, getFile } from '/lib/data/files.ts';
interface Data {}
@@ -35,7 +35,9 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
filePath = decodeURIComponent(filePath);
const rootPath = join(getFilesRootPath(), context.state.user.id);
const userId = context.state.user.id;
const rootPath = join(getFilesRootPath(), userId);
if (request.method === 'OPTIONS') {
const headers = new Headers({
@@ -51,7 +53,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (request.method === 'GET') {
try {
const fileResult = await getFile(context.state.user.id, filePath);
const fileResult = await getFile(userId, filePath);
if (!fileResult.success) {
return new Response('Not Found', { status: 404 });
@@ -74,6 +76,8 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (request.method === 'DELETE') {
try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
await Deno.remove(join(rootPath, filePath));
return new Response(null, { status: 204 });
@@ -90,6 +94,8 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const body = contentLength === 0 ? new Blob([new Uint8Array([0])]).stream() : request.clone().body;
try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
const newFile = await Deno.open(join(rootPath, filePath), {
create: true,
write: true,
@@ -110,6 +116,9 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const newFilePath = request.headers.get('destination');
if (newFilePath) {
try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath));
await Deno.copyFile(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath)));
return new Response('Created', { status: 201 });
} catch (error) {
@@ -124,6 +133,9 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const newFilePath = request.headers.get('destination');
if (newFilePath) {
try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
ensureUserPathIsValidAndSecurelyAccessible(userId, getProperDestinationPath(newFilePath));
await Deno.rename(join(rootPath, filePath), join(rootPath, getProperDestinationPath(newFilePath)));
return new Response('Created', { status: 201 });
} catch (error) {
@@ -134,6 +146,7 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
if (request.method === 'MKCOL') {
try {
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
await Deno.mkdir(join(rootPath, filePath), { recursive: true });
return new Response('Created', { status: 201 });
} catch (error) {
@@ -209,6 +222,9 @@ export const handler: Handler<Data, FreshContextState> = async (request, context
const parsedXml = parse(xml);
const properties = getPropertyNames(parsedXml);
ensureUserPathIsValidAndSecurelyAccessible(userId, filePath);
const responseXml = await buildPropFindResponse(properties, rootPath, filePath, depth);
return responseXml['D:multistatus']['D:response'].length === 0

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@@ -0,0 +1,121 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
data-v-fde0c5aa=""
viewBox="0 0 80 80"
class="iconLeft"
version="1.1"
id="svg3"
sodipodi:docname="favicon-dark.svg"
inkscape:export-filename="favicon-dark.png"
inkscape:export-xdpi="1228.8"
inkscape:export-ydpi="1228.8"
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
width="80"
height="80"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.92"
inkscape:cx="67.96875"
inkscape:cy="15.104167"
inkscape:window-width="1312"
inkscape:window-height="449"
inkscape:window-x="0"
inkscape:window-y="38"
inkscape:window-maximized="0"
inkscape:current-layer="g5" />
<!---->
<defs
data-v-fde0c5aa=""
id="defs1">
<!---->
<linearGradient
inkscape:collect="always"
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
id="linearGradient4"
gradientTransform="scale(1.3465159,0.74265739)"
x1="0"
y1="0"
x2="74.265739"
y2="0"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#d27698e6-eb24-4a3f-96af-256d84d5b3ea"
id="linearGradient5"
gradientTransform="scale(1.3465159,0.74265739)"
x1="0"
y1="0"
x2="74.265739"
y2="0"
gradientUnits="userSpaceOnUse" />
</defs>
<defs
data-v-fde0c5aa=""
id="defs2">
<!---->
</defs>
<defs
data-v-fde0c5aa=""
id="defs3">
<linearGradient
data-v-fde0c5aa=""
gradientTransform="rotate(25)"
id="d27698e6-eb24-4a3f-96af-256d84d5b3ea"
x1="0"
y1="0"
x2="1"
y2="0">
<stop
data-v-fde0c5aa=""
offset="0%"
stop-color="#51A4FB"
stop-opacity="1"
id="stop2" />
<stop
data-v-fde0c5aa=""
offset="100%"
stop-color="#51A4FB"
stop-opacity="1"
id="stop3" />
</linearGradient>
</defs>
<g
id="g5"
transform="translate(-24.999993,-125)">
<rect
data-v-fde0c5aa=""
fill="#e6f2fe"
x="24.999992"
y="125"
width="80"
height="80"
class="logo-background-square"
id="rect1"
style="stroke-width:0.266666;fill:#02203a;fill-opacity:1" />
<g
data-v-fde0c5aa=""
id="f64f14cc-5390-468e-9cd6-ee0163dcbf09"
stroke="none"
fill="url(#d27698e6-eb24-4a3f-96af-256d84d5b3ea)"
transform="matrix(0.60062915,0,0,0.60062915,34.968536,148.43645)"
style="fill:url(#linearGradient5)">
<path
d="M 77.959,55.154 H 11.282 C 5.061,55.154 0,50.093 0,43.873 0,38.595 3.643,34.152 8.546,32.927 10.734,27.458 15.794,23.541 21.695,22.838 22.317,10.14 32.843,0 45.693,0 55.11,0 63.515,5.536 67.416,13.756 a 21.944,21.944 0 0 1 10.543,-2.682 c 12.153,0 22.041,9.887 22.041,22.04 0,12.153 -9.887,22.04 -22.041,22.04 z M 11.209,40.372 a 3.507,3.507 0 0 0 -3.429,3.501 3.504,3.504 0 0 0 3.501,3.501 h 66.678 c 7.863,0 14.26,-6.397 14.26,-14.26 0,-7.863 -6.396,-14.259 -14.26,-14.259 -3.683,0 -7.18,1.404 -9.847,3.952 L 63.016,27.678 61.612,20.77 C 60.083,13.243 53.388,7.78 45.693,7.78 c -8.958,0 -16.247,7.288 -16.247,16.246 0,0.728 0.054,1.483 0.159,2.246 l 0.73,5.287 -5.256,-0.923 a 8.59,8.59 0 0 0 -1.474,-0.132 c -4.002,0 -7.479,2.842 -8.267,6.757 l -0.65,3.227 -3.29,-0.106 z"
id="path3"
style="fill:url(#linearGradient4)" />
</g>
</g>
<!---->
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@@ -3,20 +3,31 @@
"short_name": "bewCloud",
"icons": [
{
"src": "/images/logomark.svg",
"src": "/images/favicon-dark.svg",
"sizes": "1024x1024 any",
"type": "image/svg+xml",
"purpose": "monochrome maskable"
"purpose": "any maskable"
},
{
"src": "/images/favicon.png",
"src": "/images/favicon-dark.png",
"sizes": "1024x1024",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
}
],
"theme_color": "#51a4fb",
"theme_color": "#1e293b",
"background_color": "#1e293b",
"start_url": "/",
"display": "standalone",
"orientation": "portrait"
"orientation": "portrait",
"shortcuts": [
{
"name": "News",
"url": "/news"
},
{
"name": "Expenses",
"url": "/expenses"
}
]
}