Enhance security and offline functionality
- Implement stricter security measures in the Electron app, including navigation blocking, URL validation, and external request handling. - Add offline mode handling and UI improvements in components like `ScribeFooterBar` and `AddNewBookForm`. - Refactor `DeleteBook` logic to include offline sync methods. - Improve user feedback for online/offline states and synchronization errors.
This commit is contained in:
@@ -3,12 +3,13 @@ import {EditorContext} from "@/context/EditorContext";
|
|||||||
import {useContext, useEffect, useState} from "react";
|
import {useContext, useEffect, useState} from "react";
|
||||||
import {Editor} from "@tiptap/react";
|
import {Editor} from "@tiptap/react";
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
import {faBook, faChartSimple, faHeart, faSheetPlastic} from "@fortawesome/free-solid-svg-icons";
|
import {faBook, faChartSimple, faHeart, faSheetPlastic, faHardDrive} from "@fortawesome/free-solid-svg-icons";
|
||||||
import {SessionContext} from "@/context/SessionContext";
|
import {SessionContext} from "@/context/SessionContext";
|
||||||
import {useTranslations} from "next-intl";
|
import {useTranslations} from "next-intl";
|
||||||
import {AlertContext} from "@/context/AlertContext";
|
import {AlertContext} from "@/context/AlertContext";
|
||||||
import {BookContext} from "@/context/BookContext";
|
import {BookContext} from "@/context/BookContext";
|
||||||
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
import {BooksSyncContext, BooksSyncContextProps} from "@/context/BooksSyncContext";
|
||||||
|
import OfflineContext, {OfflineContextType} from "@/context/OfflineContext";
|
||||||
|
|
||||||
export default function ScribeFooterBar() {
|
export default function ScribeFooterBar() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -16,6 +17,7 @@ export default function ScribeFooterBar() {
|
|||||||
const {book} = useContext(BookContext);
|
const {book} = useContext(BookContext);
|
||||||
const editor: Editor | null = useContext(EditorContext).editor;
|
const editor: Editor | null = useContext(EditorContext).editor;
|
||||||
const {errorMessage} = useContext(AlertContext)
|
const {errorMessage} = useContext(AlertContext)
|
||||||
|
const {offlineMode} = useContext<OfflineContextType>(OfflineContext)
|
||||||
const {serverOnlyBooks,localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
const {serverOnlyBooks,localOnlyBooks} = useContext<BooksSyncContextProps>(BooksSyncContext);
|
||||||
|
|
||||||
const [wordsCount, setWordsCount] = useState<number>(0);
|
const [wordsCount, setWordsCount] = useState<number>(0);
|
||||||
@@ -48,6 +50,12 @@ export default function ScribeFooterBar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(localOnlyBooks.length > 0 || offlineMode.isOffline);
|
||||||
|
console.log(localOnlyBooks.length);
|
||||||
|
console.log(offlineMode.isOffline);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="px-6 py-3 bg-tertiary/90 backdrop-blur-sm border-t border-secondary/50 text-text-primary flex justify-between items-center shadow-lg">
|
className="px-6 py-3 bg-tertiary/90 backdrop-blur-sm border-t border-secondary/50 text-text-primary flex justify-between items-center shadow-lg">
|
||||||
@@ -88,12 +96,20 @@ export default function ScribeFooterBar() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<div
|
{
|
||||||
className="flex items-center gap-2 bg-secondary/50 px-4 py-2 rounded-xl border border-secondary shadow-sm">
|
!offlineMode.isOffline && <div
|
||||||
<FontAwesomeIcon icon={faBook} className={'text-primary w-4 h-4'}/>
|
className="flex items-center gap-2 bg-secondary/50 px-4 py-2 rounded-xl border border-secondary shadow-sm">
|
||||||
<span className="text-muted text-sm font-medium mr-1">{t('scribeFooterBar.books')}:</span>
|
<FontAwesomeIcon icon={faBook} className={'text-primary w-4 h-4'}/>
|
||||||
<span className="text-text-primary font-bold">{(serverOnlyBooks.length+localOnlyBooks.length)}</span>
|
<span className="text-text-primary font-bold">{serverOnlyBooks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
{(localOnlyBooks.length > 0 || offlineMode.isOffline) && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 bg-secondary/50 px-4 py-2 rounded-xl border border-secondary shadow-sm">
|
||||||
|
<FontAwesomeIcon icon={faHardDrive} className={'text-primary w-4 h-4'}/>
|
||||||
|
<span className="text-text-primary font-bold">{localOnlyBooks.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export default function AddNewBookForm({setCloseForm}: { setCloseForm: Dispatch<
|
|||||||
bookId: bookId,
|
bookId: bookId,
|
||||||
...bookData
|
...bookData
|
||||||
};
|
};
|
||||||
|
console.log(isCurrentlyOffline())
|
||||||
if (isCurrentlyOffline()){
|
if (isCurrentlyOffline()){
|
||||||
setLocalOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
|
setLocalOnlyBooks((prevBooks: SyncedBook[]): SyncedBook[] => [...prevBooks, {
|
||||||
id: book.bookId,
|
id: book.bookId,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export default function BookList() {
|
|||||||
session.isConnected,
|
session.isConnected,
|
||||||
accessToken,
|
accessToken,
|
||||||
offlineMode.isDatabaseInitialized,
|
offlineMode.isDatabaseInitialized,
|
||||||
|
offlineMode.isOffline,
|
||||||
booksToSyncFromServer,
|
booksToSyncFromServer,
|
||||||
booksToSyncToServer,
|
booksToSyncToServer,
|
||||||
serverOnlyBooks,
|
serverOnlyBooks,
|
||||||
@@ -350,9 +351,8 @@ export default function BookList() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<div className="text-center p-8 max-w-lg">
|
<div className="text-center p-8 max-w-lg">
|
||||||
<div
|
<div className="w-24 h-24 bg-primary/20 text-primary rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||||
className="w-24 h-24 bg-primary/20 text-primary rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg animate-pulse">
|
<FontAwesomeIcon icon={faBook} size={'3x'}/>
|
||||||
<FontAwesomeIcon icon={faBook} className={'w-12 h-12'}/>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-4xl font-['ADLaM_Display'] mb-4 text-text-primary">{t("bookList.welcomeWritingWorkshop")}</h2>
|
<h2 className="text-4xl font-['ADLaM_Display'] mb-4 text-text-primary">{t("bookList.welcomeWritingWorkshop")}</h2>
|
||||||
<p className="text-muted mb-6 text-lg leading-relaxed">
|
<p className="text-muted mb-6 text-lg leading-relaxed">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {faTrash} from "@fortawesome/free-solid-svg-icons";
|
|||||||
import {useContext, useState} from "react";
|
import {useContext, useState} from "react";
|
||||||
import System from "@/lib/models/System";
|
import System from "@/lib/models/System";
|
||||||
import {SessionContext} from "@/context/SessionContext";
|
import {SessionContext} from "@/context/SessionContext";
|
||||||
import {BookProps} from "@/lib/models/Book";
|
|
||||||
import {LangContext, LangContextProps} from "@/context/LangContext";
|
import {LangContext, LangContextProps} from "@/context/LangContext";
|
||||||
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
import {AlertContext, AlertContextProps} from "@/context/AlertContext";
|
||||||
import AlertBox from "@/components/AlertBox";
|
import AlertBox from "@/components/AlertBox";
|
||||||
@@ -43,6 +42,9 @@ export default function DeleteBook({bookId}: DeleteBookProps) {
|
|||||||
id: bookId,
|
id: bookId,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
response = await window.electron.invoke<boolean>('db:book:delete', {
|
||||||
|
id: bookId,
|
||||||
|
});
|
||||||
response = await System.authDeleteToServer<boolean>(
|
response = await System.authDeleteToServer<boolean>(
|
||||||
`book/delete`,
|
`book/delete`,
|
||||||
{
|
{
|
||||||
|
|||||||
115
electron/main.ts
115
electron/main.ts
@@ -64,6 +64,13 @@ function createLoginWindow(): void {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
|
webSecurity: true,
|
||||||
|
allowRunningInsecureContent: false,
|
||||||
|
experimentalFeatures: false,
|
||||||
|
enableBlinkFeatures: '',
|
||||||
|
disableBlinkFeatures: '',
|
||||||
|
webviewTag: false,
|
||||||
|
navigateOnDragDrop: false,
|
||||||
},
|
},
|
||||||
frame: true,
|
frame: true,
|
||||||
show: false,
|
show: false,
|
||||||
@@ -84,6 +91,25 @@ function createLoginWindow(): void {
|
|||||||
loginWindow.on('closed', () => {
|
loginWindow.on('closed', () => {
|
||||||
loginWindow = null;
|
loginWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Security: Block navigation to external domains
|
||||||
|
loginWindow.webContents.on('will-navigate', (event, navigationUrl) => {
|
||||||
|
const parsedUrl = new URL(navigationUrl);
|
||||||
|
if (isDev) {
|
||||||
|
if (!parsedUrl.origin.startsWith('http://localhost')) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (parsedUrl.protocol !== 'scribedesktop:') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security: Block new window creation
|
||||||
|
loginWindow.webContents.setWindowOpenHandler(() => {
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMainWindow(): void {
|
function createMainWindow(): void {
|
||||||
@@ -97,6 +123,13 @@ function createMainWindow(): void {
|
|||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
|
webSecurity: true,
|
||||||
|
allowRunningInsecureContent: false,
|
||||||
|
experimentalFeatures: false,
|
||||||
|
enableBlinkFeatures: '',
|
||||||
|
disableBlinkFeatures: '',
|
||||||
|
webviewTag: false,
|
||||||
|
navigateOnDragDrop: false,
|
||||||
},
|
},
|
||||||
show: false,
|
show: false,
|
||||||
});
|
});
|
||||||
@@ -116,11 +149,43 @@ function createMainWindow(): void {
|
|||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
mainWindow = null;
|
mainWindow = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Security: Block navigation to external domains
|
||||||
|
mainWindow.webContents.on('will-navigate', (event, navigationUrl) => {
|
||||||
|
const parsedUrl = new URL(navigationUrl);
|
||||||
|
if (isDev) {
|
||||||
|
if (!parsedUrl.origin.startsWith('http://localhost')) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (parsedUrl.protocol !== 'scribedesktop:') {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security: Block new window creation
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(() => {
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPC Handler pour ouvrir des liens externes (navigateur/app native)
|
// IPC Handler pour ouvrir des liens externes (navigateur/app native)
|
||||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||||
await shell.openExternal(url);
|
// Security: Validate URL before opening
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const allowedProtocols = ['http:', 'https:', 'mailto:'];
|
||||||
|
|
||||||
|
if (!allowedProtocols.includes(parsedUrl.protocol)) {
|
||||||
|
console.error('[Security] Blocked external URL with invalid protocol:', parsedUrl.protocol);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await shell.openExternal(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Security] Invalid URL rejected:', url);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// IPC Handlers pour la gestion du token (OS-encrypted storage)
|
// IPC Handlers pour la gestion du token (OS-encrypted storage)
|
||||||
@@ -347,8 +412,39 @@ ipcMain.handle('db-initialize', (_event, userId: string, encryptionKey: string)
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.whenReady().then(():void => {
|
app.whenReady().then(():void => {
|
||||||
// Menu minimal pour garder les raccourcis DevTools
|
// Security: Disable web cache in production
|
||||||
|
if (!isDev) {
|
||||||
|
app.commandLine.appendSwitch('disable-http-cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: Set permissions request handler
|
||||||
|
app.on('web-contents-created', (_event, contents) => {
|
||||||
|
// Allow only clipboard permissions, block others
|
||||||
|
contents.session.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||||
|
const allowedPermissions: string[] = ['clipboard-read', 'clipboard-sanitized-write'];
|
||||||
|
callback(allowedPermissions.includes(permission));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Block all web requests to file:// protocol
|
||||||
|
contents.session.protocol.interceptFileProtocol('file', (request, callback) => {
|
||||||
|
callback({ error: -3 }); // net::ERR_ABORTED
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Menu minimal pour garder les raccourcis DevTools et clipboard
|
||||||
const template: Electron.MenuItemConstructorOptions[] = [
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
|
{
|
||||||
|
label: 'Edit',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'undo' },
|
||||||
|
{ role: 'redo' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'cut' },
|
||||||
|
{ role: 'copy' },
|
||||||
|
{ role: 'paste' },
|
||||||
|
{ role: 'selectAll' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'View',
|
label: 'View',
|
||||||
submenu: [
|
submenu: [
|
||||||
@@ -364,10 +460,20 @@ app.whenReady().then(():void => {
|
|||||||
const outPath:string = path.join(process.resourcesPath, 'app.asar.unpacked/out');
|
const outPath:string = path.join(process.resourcesPath, 'app.asar.unpacked/out');
|
||||||
|
|
||||||
protocol.handle('scribedesktop', async (request) => {
|
protocol.handle('scribedesktop', async (request) => {
|
||||||
|
// Security: Validate and sanitize file path
|
||||||
let filePath:string = request.url.replace('scribedesktop://', '').replace(/^\.\//, '');
|
let filePath:string = request.url.replace('scribedesktop://', '').replace(/^\.\//, '');
|
||||||
|
|
||||||
|
// Security: Block path traversal attempts
|
||||||
|
if (filePath.includes('..') || filePath.includes('~')) {
|
||||||
|
console.error('[Security] Path traversal attempt blocked:', filePath);
|
||||||
|
return new Response('Forbidden', { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const fullPath:string = path.normalize(path.join(outPath, filePath));
|
const fullPath:string = path.normalize(path.join(outPath, filePath));
|
||||||
|
|
||||||
|
// Security: Ensure path is within allowed directory
|
||||||
if (!fullPath.startsWith(outPath)) {
|
if (!fullPath.startsWith(outPath)) {
|
||||||
|
console.error('[Security] Path escape attempt blocked:', fullPath);
|
||||||
return new Response('Forbidden', { status: 403 });
|
return new Response('Forbidden', { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +495,10 @@ app.whenReady().then(():void => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return new Response(data, {
|
return new Response(data, {
|
||||||
headers: { 'Content-Type': mimeTypes[ext] || 'application/octet-stream' }
|
headers: {
|
||||||
|
'Content-Type': mimeTypes[ext] || 'application/octet-stream',
|
||||||
|
'X-Content-Type-Options': 'nosniff'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response('Not found', { status: 404 });
|
return new Response('Not found', { status: 404 });
|
||||||
|
|||||||
Reference in New Issue
Block a user