From 2bfa8e5f0d6fbdd9340c49c9343e4a9fbd99a0ae Mon Sep 17 00:00:00 2001 From: Christopher Loverich <1010084+cloverich@users.noreply.github.com> Date: Fri, 13 Sep 2024 19:05:20 -0700 Subject: [PATCH] add journal name validation - add basic validation for journal names (size, url safe, somewhat file system safe) - re-work JournalResponse type to avoid import 'path' issue --- src/hooks/useClient.ts | 8 +++-- src/hooks/useJournalsLoader.ts | 3 +- src/preload/client/journals.ts | 50 ++++++++++++++++++++++++------- src/preload/client/preferences.ts | 2 -- src/preload/client/types.ts | 8 +++++ src/views/edit/FrontMatter.tsx | 2 +- src/views/edit/PlateContainer.tsx | 8 ++--- src/views/edit/index.tsx | 2 +- 8 files changed, 60 insertions(+), 23 deletions(-) diff --git a/src/hooks/useClient.ts b/src/hooks/useClient.ts index 3fff02b..ce15bcc 100644 --- a/src/hooks/useClient.ts +++ b/src/hooks/useClient.ts @@ -1,15 +1,19 @@ import React, { useContext } from "react"; import { IClient } from "../preload/client/types"; -export { IClient } from "../preload/client/types"; export { SearchResponse } from "../preload/client/documents"; -export { JournalResponse } from "../preload/client/journals"; +export { IClient, JournalResponse } from "../preload/client/types"; export const ClientContext = React.createContext( (window as any).chronicles.createClient(), ); ClientContext.displayName = "ClientContext"; +/** + * Hook to get the client that separates "server side" from the UI. + * + * Note that this is the only safe place for UI code to access the client. + */ export default function useClient(): IClient { return useContext(ClientContext); } diff --git a/src/hooks/useJournalsLoader.ts b/src/hooks/useJournalsLoader.ts index eb55093..a2fd1f6 100644 --- a/src/hooks/useJournalsLoader.ts +++ b/src/hooks/useJournalsLoader.ts @@ -1,7 +1,6 @@ import React from "react"; -import { JournalResponse } from "../preload/client/journals"; import { JournalsStore } from "./stores/journals"; -import useClient from "./useClient"; +import useClient, { JournalResponse } from "./useClient"; export const JournalsStoreContext = React.createContext( null, diff --git a/src/preload/client/journals.ts b/src/preload/client/journals.ts index c305b1e..3399cdf 100644 --- a/src/preload/client/journals.ts +++ b/src/preload/client/journals.ts @@ -1,13 +1,8 @@ import { Database } from "better-sqlite3"; +import path from "path"; import { uuidv7 } from "uuidv7"; -export interface JournalResponse { - id: string; - name: string; - createdAt: string; - updatedAt: string; - archivedAt: string; -} +import { JournalResponse } from "./types"; export type IJournalsClient = JournalsClient; @@ -19,6 +14,7 @@ export class JournalsClient { }; create = (journal: { name: string }): Promise => { + const name = validateJournalName(journal.name); const id = uuidv7(); this.db @@ -26,8 +22,8 @@ export class JournalsClient { "insert into journals (id, name, createdAt, updatedAt) values (:id, :name, :createdAt, :updatedAt)", ) .run({ - name: journal.name, id, + name, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }); @@ -39,14 +35,14 @@ export class JournalsClient { id: string; name: string; }): Promise => { - if (!journal.name?.trim()) throw new Error("Journal name cannot be empty"); + const name = validateJournalName(journal.name); this.db .prepare( "update journals set name = :name, updatedAt = :updatedAt where id = :id", ) .run({ - name: journal.name, + name, id: journal.id, updatedAt: new Date().toISOString(), }); @@ -85,3 +81,37 @@ export class JournalsClient { return this.list(); }; } + +const MAX_NAME_LENGTH = 20; + +/** + * A basic validation function for journal names. + */ +const validateJournalName = (name: string): string => { + name = name?.trim() || ""; + if (!name) { + throw new Error("Journal name cannot be empty."); + } + + // Check for max length + if (name.length > MAX_NAME_LENGTH) { + throw new Error( + `Journal name exceeds max length of ${MAX_NAME_LENGTH} characters.`, + ); + } + + let sanitized = decodeURIComponent(encodeURIComponent(name)); + + // Check for URL safety + if (name !== sanitized) { + throw new Error("Journal name is not URL safe."); + } + + // Ensure the name doesn't contain path traversal or invalid slashes + sanitized = path.basename(name); + if (sanitized !== name) { + throw new Error("Journal name contains invalid path characters."); + } + + return sanitized; +}; diff --git a/src/preload/client/preferences.ts b/src/preload/client/preferences.ts index d45320e..47be68a 100644 --- a/src/preload/client/preferences.ts +++ b/src/preload/client/preferences.ts @@ -17,8 +17,6 @@ export class PreferencesClient { return settingsJson as unknown as Preferences; }; - // todo: Ideally this could go into a preload script - // see the main script (electron/index) for the other half openDialog = () => { ipcRenderer.send("select-database-file"); }; diff --git a/src/preload/client/types.ts b/src/preload/client/types.ts index 76b7f82..255fabd 100644 --- a/src/preload/client/types.ts +++ b/src/preload/client/types.ts @@ -15,3 +15,11 @@ export interface IClient { preferences: IPreferencesClient; files: IFilesClient; } + +export type JournalResponse = { + id: string; + name: string; + createdAt: string; + updatedAt: string; + archivedAt: string; +}; diff --git a/src/views/edit/FrontMatter.tsx b/src/views/edit/FrontMatter.tsx index 25b24ed..350b5a3 100644 --- a/src/views/edit/FrontMatter.tsx +++ b/src/views/edit/FrontMatter.tsx @@ -4,7 +4,7 @@ import { DayPicker } from "react-day-picker"; import * as D from "../../components/DropdownMenu"; import * as Popover from "../../components/Popover"; import TagInput from "../../components/TagInput"; -import { JournalResponse } from "../../preload/client/journals"; +import { JournalResponse } from "../../hooks/useClient"; import { TagTokenParser } from "../documents/search/parsers/tag"; import { EditableDocument } from "./EditableDocument"; diff --git a/src/views/edit/PlateContainer.tsx b/src/views/edit/PlateContainer.tsx index 59b83e0..9b556bf 100644 --- a/src/views/edit/PlateContainer.tsx +++ b/src/views/edit/PlateContainer.tsx @@ -102,7 +102,9 @@ import { createVideoPlugin, } from "./editor/plugins/createVideoPlugin"; -import { JournalResponse } from "../../hooks/useClient"; +import useClient, { JournalResponse } from "../../hooks/useClient"; +import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; +import { SearchStore } from "../documents/SearchStore"; import { EditableDocument } from "./EditableDocument"; import { EditorMode } from "./EditorMode"; @@ -116,10 +118,6 @@ export interface Props { setSelectedEditorMode: (s: EditorMode) => any; } -import useClient from "../../hooks/useClient"; -import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; -import { SearchStore } from "../documents/SearchStore"; - export default observer( ({ children, saving, value, setValue }: React.PropsWithChildren) => { const jstore = useContext(JournalsStoreContext); diff --git a/src/views/edit/index.tsx b/src/views/edit/index.tsx index 79fb131..fee9aa8 100644 --- a/src/views/edit/index.tsx +++ b/src/views/edit/index.tsx @@ -2,8 +2,8 @@ import { ChevronLeftIcon, IconButton, Pane } from "evergreen-ui"; import { observer } from "mobx-react-lite"; import React, { useContext, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; +import { JournalResponse } from "../../hooks/useClient"; import { JournalsStoreContext } from "../../hooks/useJournalsLoader"; -import { JournalResponse } from "../../preload/client/journals"; import Titlebar from "../../titlebar/macos"; import { useSearchStore } from "../documents/SearchStore"; import * as Base from "../layout";