fix(security): patch 3 vulnerabilities — IDOR, ownership bypass, XSS

V1: Add owner-scoped folder pagination (list_folders_by_owner_paginated)
  - New method in FolderRepository trait, PG implementation, service & handler
  - Prevents IDOR by filtering folder listings to authenticated user

V2: Enforce ownership checks on folder mutations
  - rename_folder, move_folder, delete_folder now require caller_id
  - Service verifies folder.owner_id == caller_id (returns 404 on mismatch)
  - Propagated to folder_handler, batch_handler, batch_operations, webdav_handler
  - delete_folder_with_trash upgraded from OptionalAuthUser to AuthUser
  - download_folder_zip now checks ownership before streaming

V3: Fix XSS in frontend via DOM APIs
  - sharedView.js: innerHTML → createElement + textContent
  - contextMenus.js: innerHTML → DOM construction for share dialog

Cleanup: removed unused OptionalAuthUser import, updated all stubs/mocks
This commit is contained in:
Dionisio
2026-02-16 00:22:42 +01:00
parent 66b4acd9d6
commit 5a679dfc90
25 changed files with 684 additions and 269 deletions
+239 -193
View File
@@ -1,89 +1,75 @@
/**
* OxiCloud - Multi-Select & Batch Actions Module
* Adds checkboxes to both grid and list views, a batch action bar,
* and batch delete / move / download operations.
*
* Adds checkboxes to grid and list views, replaces the list-view header
* with a NextCloud-style selection bar when items are selected, and
* provides batch delete / move / download / favorites operations.
*/
const multiSelect = {
/** Currently selected items: { id, name, type: 'file'|'folder', parentId } */
/** Currently selected items: Map<id, { id, name, type, parentId }> */
_selected: new Map(),
/** Last clicked index for Shift-range selection */
_lastClickedIndex: -1,
/** Whether the batch bar is currently visible */
/** Whether the selection bar is currently visible */
_barVisible: false,
/** Saved original list-header HTML so we can restore it */
_savedHeaderHTML: '',
// ── Public API ──────────────────────────────────────────
/** Number of selected items */
get count() { return this._selected.size; },
/** All selected items as an array */
get items() { return Array.from(this._selected.values()); },
/** True when at least one item is selected */
get count() { return this._selected.size; },
get items() { return Array.from(this._selected.values()); },
get hasSelection() { return this._selected.size > 0; },
get files() { return this.items.filter(i => i.type === 'file'); },
get folders() { return this.items.filter(i => i.type === 'folder'); },
/** Get selected files only */
get files() { return this.items.filter(i => i.type === 'file'); },
// ── Helpers for i18n ────────────────────────────────────
/** Get selected folders only */
get folders() { return this.items.filter(i => i.type === 'folder'); },
_t(key, vars) {
if (window.i18n && typeof window.i18n.t === 'function') {
const val = window.i18n.t(key, vars);
// If i18n returned the key itself, it's missing → fall back
if (val && val !== key) return val;
}
return null;
},
// ── Selection state management ──────────────────────────
/**
* Toggle an item in the selection.
* @param {string} id
* @param {string} name
* @param {'file'|'folder'} type
* @param {string} parentId parent / folder id
* @returns {boolean} new selected state
*/
toggle(id, name, type, parentId) {
if (this._selected.has(id)) {
this._selected.delete(id);
return false;
}
if (this._selected.has(id)) { this._selected.delete(id); return false; }
this._selected.set(id, { id, name, type, parentId });
return true;
},
/** Select a single item (add if not present) */
select(id, name, type, parentId) {
this._selected.set(id, { id, name, type, parentId });
},
/** Deselect a single item */
deselect(id) {
this._selected.delete(id);
},
deselect(id) { this._selected.delete(id); },
/** Clear the whole selection */
clear() {
this._selected.clear();
this._lastClickedIndex = -1;
// Remove visual state from DOM
document.querySelectorAll('.file-card.selected, .file-item.selected').forEach(el => {
el.classList.remove('selected');
});
// Uncheck all item checkboxes
document.querySelectorAll('.file-card.selected, .file-item.selected')
.forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.item-checkbox').forEach(cb => cb.checked = false);
this._syncUI();
},
/** Select all visible items */
selectAll() {
this._selectAllInContainer('files-grid', '.file-card');
this._selectAllInContainer('files-list-view', '.file-item');
this._syncUI();
},
/** Deselect/select all toggle */
toggleAll() {
const allItems = this._getAllVisibleItems();
if (this._selected.size === allItems.length && allItems.length > 0) {
if (this._selected.size >= allItems.length && allItems.length > 0) {
this.clear();
} else {
this.selectAll();
@@ -92,7 +78,6 @@ const multiSelect = {
// ── DOM helpers ─────────────────────────────────────────
/** Gather info from a DOM element and add to selection */
_selectElement(el) {
const info = this._extractInfo(el);
if (info) {
@@ -108,62 +93,41 @@ const multiSelect = {
},
_getAllVisibleItems() {
const gridItems = [...document.querySelectorAll('#files-grid .file-card')];
const listItems = [...document.querySelectorAll('#files-list-view .file-item')];
// Only return items from the currently visible view
const grid = document.getElementById('files-grid');
if (grid && grid.style.display !== 'none') return gridItems;
return listItems;
if (grid && grid.style.display !== 'none') {
return [...grid.querySelectorAll('.file-card')];
}
return [...document.querySelectorAll('#files-list-view .file-item')];
},
/** Extract item info from a DOM element */
_extractInfo(el) {
if (el.dataset.folderId && el.dataset.folderName !== undefined) {
return {
id: el.dataset.folderId,
name: el.dataset.folderName,
type: 'folder',
parentId: el.dataset.parentId || ''
};
return { id: el.dataset.folderId, name: el.dataset.folderName, type: 'folder', parentId: el.dataset.parentId || '' };
}
if (el.dataset.fileId) {
return {
id: el.dataset.fileId,
name: el.dataset.fileName,
type: 'file',
parentId: el.dataset.folderId || ''
};
return { id: el.dataset.fileId, name: el.dataset.fileName, type: 'file', parentId: el.dataset.folderId || '' };
}
return null;
},
// ── Click handler (shared by grid + list) ───────────────
/**
* Handle a checkbox/selection click on an item element.
* Supports Shift-click for range selection.
*/
handleItemClick(el, event) {
const items = this._getAllVisibleItems();
const index = items.indexOf(el);
// Also find the matching element in the other view
const info = this._extractInfo(el);
if (!info) return;
const selectorOther = info.type === 'folder'
? `[data-folder-id="${info.id}"]`
: `[data-file-id="${info.id}"]`;
const otherEl = [...document.querySelectorAll(selectorOther)]
.find(e => e !== el);
const otherEl = [...document.querySelectorAll(selectorOther)].find(e => e !== el);
if (event && event.shiftKey && this._lastClickedIndex >= 0 && index >= 0) {
// Range selection
const start = Math.min(this._lastClickedIndex, index);
const end = Math.max(this._lastClickedIndex, index);
for (let i = start; i <= end; i++) {
this._selectElement(items[i]);
// Mirror to other view
const iInfo = this._extractInfo(items[i]);
if (iInfo) {
const sel = iInfo.type === 'folder'
@@ -173,96 +137,184 @@ const multiSelect = {
}
}
} else {
// Normal toggle
const nowSelected = this.toggle(info.id, info.name, info.type, info.parentId);
el.classList.toggle('selected', nowSelected);
if (otherEl) otherEl.classList.toggle('selected', nowSelected);
}
this._lastClickedIndex = index;
this._syncUI();
},
// ── Batch action bar ────────────────────────────────────
// ── Selection bar (replaces list-header when items selected) ────
/** Create the batch action bar if it doesn't exist */
_ensureBar() {
if (document.getElementById('batch-action-bar')) return;
/**
* Build the inner HTML for the selection bar that replaces the
* normal list-header columns (Name / Type / Size / Modified).
*/
_buildSelectionBarHTML(n) {
const countText = n === 1
? (this._t('batch.one_selected') || '1 item selected')
: (this._t('batch.n_selected', { count: n }) || `${n} items selected`);
const bar = document.createElement('div');
bar.id = 'batch-action-bar';
bar.className = 'batch-action-bar';
bar.innerHTML = `
<div class="batch-bar-left">
<button class="batch-bar-close" id="batch-bar-close" title="Cancel selection">
<i class="fas fa-times"></i>
</button>
<span class="batch-bar-count" id="batch-bar-count">0 selected</span>
const favLabel = this._t('batch.add_favorites') || 'Add to favorites';
const moveLabel = this._t('batch.move_copy') || 'Move or copy';
const dlLabel = this._t('actions.download') || 'Download';
const delLabel = this._t('actions.delete') || 'Delete';
return `
<div class="list-header-checkbox">
<input type="checkbox" id="select-all-checkbox" title="Toggle all" checked>
</div>
<div class="batch-bar-actions">
<button class="batch-btn" id="batch-download" title="Download">
<i class="fas fa-download"></i>
<span data-i18n="actions.download">Download</span>
</button>
<button class="batch-btn" id="batch-move" title="Move">
<i class="fas fa-arrows-alt"></i>
<span data-i18n="actions.move">Move</span>
</button>
<button class="batch-btn batch-btn-danger" id="batch-delete" title="Delete">
<i class="fas fa-trash-alt"></i>
<span data-i18n="actions.delete">Delete</span>
</button>
<div class="batch-selection-info">
<span class="batch-bar-count">${countText}</span>
<div class="batch-bar-actions">
<button class="batch-btn" id="batch-fav" title="${favLabel}">
<i class="fas fa-star"></i>
<span>${favLabel}</span>
</button>
<button class="batch-btn" id="batch-move" title="${moveLabel}">
<i class="fas fa-arrows-alt"></i>
<span>${moveLabel}</span>
</button>
<button class="batch-btn" id="batch-download" title="${dlLabel}">
<i class="fas fa-download"></i>
<span>${dlLabel}</span>
</button>
<button class="batch-btn batch-btn-danger" id="batch-delete" title="${delLabel}">
<i class="fas fa-trash-alt"></i>
<span>${delLabel}</span>
</button>
</div>
</div>
`;
// Insert before the files-container (inside main-content)
const filesContainer = document.querySelector('.files-container');
if (filesContainer && filesContainer.parentNode) {
filesContainer.parentNode.insertBefore(bar, filesContainer);
} else {
document.body.appendChild(bar);
}
// Wire up events
document.getElementById('batch-bar-close').addEventListener('click', () => this.clear());
document.getElementById('batch-delete').addEventListener('click', () => this.batchDelete());
document.getElementById('batch-move').addEventListener('click', () => this.batchMove());
document.getElementById('batch-download').addEventListener('click', () => this.batchDownload());
},
/** Show/hide the bar and update the count */
/** Ensure the grid-view batch bar exists (shown only when grid is visible) */
_ensureGridBar() {
if (document.getElementById('batch-grid-bar')) return;
const bar = document.createElement('div');
bar.id = 'batch-grid-bar';
bar.className = 'batch-action-bar'; // reuse same styles
const container = document.querySelector('.files-container');
if (container) {
container.insertBefore(bar, container.firstChild);
}
},
/** Main UI sync — called after every selection change */
_syncUI() {
this._ensureBar();
const bar = document.getElementById('batch-action-bar');
const count = document.getElementById('batch-bar-count');
const listHeader = document.querySelector('.list-header');
const n = this._selected.size;
if (this._selected.size > 0) {
bar.classList.add('visible');
this._barVisible = true;
const n = this._selected.size;
const itemsText = n === 1
? (window.i18n ? window.i18n.t('batch.one_selected') : '1 item selected')
: (window.i18n ? window.i18n.t('batch.n_selected', { count: n }) : `${n} items selected`);
count.textContent = itemsText;
} else {
bar.classList.remove('visible');
this._barVisible = false;
// ── Save original header HTML on first use ──
if (listHeader && !this._savedHeaderHTML) {
this._savedHeaderHTML = listHeader.innerHTML;
}
// Update select-all checkbox state
this._syncSelectAllCheckbox();
if (n > 0) {
this._barVisible = true;
// Sync individual list-view checkboxes
// ── List view: replace header with selection bar ──
if (listHeader) {
listHeader.classList.add('selection-mode');
listHeader.innerHTML = this._buildSelectionBarHTML(n);
// Wire checkbox
const cb = document.getElementById('select-all-checkbox');
if (cb) cb.addEventListener('change', () => this.toggleAll());
// Wire action buttons
this._wireBarButtons();
}
// ── Grid view: show floating bar ──
this._ensureGridBar();
const gridBar = document.getElementById('batch-grid-bar');
if (gridBar) {
const grid = document.getElementById('files-grid');
const gridVisible = grid && grid.style.display !== 'none';
if (gridVisible) {
gridBar.classList.add('visible');
gridBar.innerHTML = `
<div class="batch-bar-left">
<button class="batch-bar-close" id="batch-grid-close" title="Cancel selection">
<i class="fas fa-times"></i>
</button>
<span class="batch-bar-count">${
n === 1
? (this._t('batch.one_selected') || '1 item selected')
: (this._t('batch.n_selected', { count: n }) || `${n} items selected`)
}</span>
</div>
<div class="batch-bar-actions">
<button class="batch-btn" id="batch-fav" title="Add to favorites">
<i class="fas fa-star"></i>
<span>${this._t('batch.add_favorites') || 'Add to favorites'}</span>
</button>
<button class="batch-btn" id="batch-move" title="Move or copy">
<i class="fas fa-arrows-alt"></i>
<span>${this._t('batch.move_copy') || 'Move or copy'}</span>
</button>
<button class="batch-btn" id="batch-download" title="Download">
<i class="fas fa-download"></i>
<span>${this._t('actions.download') || 'Download'}</span>
</button>
<button class="batch-btn batch-btn-danger" id="batch-delete" title="Delete">
<i class="fas fa-trash-alt"></i>
<span>${this._t('actions.delete') || 'Delete'}</span>
</button>
</div>
`;
const closeBtn = document.getElementById('batch-grid-close');
if (closeBtn) closeBtn.addEventListener('click', () => this.clear());
this._wireBarButtons();
} else {
gridBar.classList.remove('visible');
}
}
} else {
this._barVisible = false;
// Restore original list header
if (listHeader) {
listHeader.classList.remove('selection-mode');
if (this._savedHeaderHTML) {
listHeader.innerHTML = this._savedHeaderHTML;
}
// Re-wire the select-all checkbox
const cb = document.getElementById('select-all-checkbox');
if (cb) cb.addEventListener('change', () => this.toggleAll());
// Translate restored header
if (window.i18n && window.i18n.translatePage) window.i18n.translatePage();
}
// Hide grid bar
const gridBar = document.getElementById('batch-grid-bar');
if (gridBar) gridBar.classList.remove('visible');
}
// Sync individual item checkboxes
this._syncItemCheckboxes();
// Sync select-all checkbox state (for non-selection-mode)
if (!this._barVisible) this._syncSelectAllCheckbox();
},
/** Wire click handlers on batch action buttons (idempotent per render) */
_wireBarButtons() {
const del = document.getElementById('batch-delete');
const move = document.getElementById('batch-move');
const dl = document.getElementById('batch-download');
const fav = document.getElementById('batch-fav');
if (del) del.onclick = () => this.batchDelete();
if (move) move.onclick = () => this.batchMove();
if (dl) dl.onclick = () => this.batchDownload();
if (fav) fav.onclick = () => this.batchFavorites();
},
/** Sync individual item checkboxes with selection state */
_syncItemCheckboxes() {
document.querySelectorAll('.file-item').forEach(el => {
const cb = el.querySelector('.item-checkbox');
if (cb) {
cb.checked = el.classList.contains('selected');
}
if (cb) cb.checked = el.classList.contains('selected');
});
},
@@ -273,7 +325,7 @@ const multiSelect = {
if (all.length === 0) {
cb.checked = false;
cb.indeterminate = false;
} else if (this._selected.size === all.length) {
} else if (this._selected.size >= all.length) {
cb.checked = true;
cb.indeterminate = false;
} else if (this._selected.size > 0) {
@@ -294,21 +346,19 @@ const multiSelect = {
const n = items.length;
const msg = n === 1
? (window.i18n
? window.i18n.t('dialogs.confirm_delete_file', { name: items[0].name })
: `Are you sure you want to move "${items[0].name}" to trash?`)
: (window.i18n
? window.i18n.t('batch.confirm_delete', { count: n })
: `Are you sure you want to move ${n} items to trash?`);
? (this._t('dialogs.confirm_delete_file', { name: items[0].name })
|| `Are you sure you want to move "${items[0].name}" to trash?`)
: (this._t('batch.confirm_delete', { count: n })
|| `Are you sure you want to move ${n} items to trash?`);
const confirmed = await showConfirmDialog({
title: window.i18n ? window.i18n.t('dialogs.confirm_delete') : 'Move to trash',
title: this._t('dialogs.confirm_delete') || 'Move to trash',
message: msg,
confirmText: window.i18n ? window.i18n.t('actions.delete') : 'Delete',
confirmText: this._t('actions.delete') || 'Delete',
});
if (!confirmed) return;
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
try {
@@ -317,21 +367,17 @@ const multiSelect = {
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
});
const data = await response.json();
const success = data.stats?.successful || 0;
const errors = data.stats?.failed || 0;
const errors = data.stats?.failed || 0;
this.clear();
window.loadFiles();
if (errors > 0) {
const failedNames = (data.failed || []).map(f => f.id).join(', ');
window.ui.showNotification('Batch delete',
`${success} moved to trash, ${errors} failed`);
window.ui.showNotification('Batch delete', `${success} moved to trash, ${errors} failed`);
} else {
window.ui.showNotification('Moved to trash',
`${success} item${success !== 1 ? 's' : ''} moved to trash`);
window.ui.showNotification('Moved to trash', `${success} item${success !== 1 ? 's' : ''} moved to trash`);
}
} catch (e) {
console.error('Batch trash error:', e);
@@ -346,26 +392,19 @@ const multiSelect = {
const items = this.items;
if (items.length === 0) return;
// Set a special batch mode flag
window.app.moveDialogMode = 'batch';
window.app.batchMoveItems = items;
// Reset selection
window.app.selectedTargetFolderId = "";
// Update dialog title
const dialog = document.getElementById('move-file-dialog');
const dialogHeader = dialog.querySelector('.rename-dialog-header');
const n = items.length;
const titleText = window.i18n
? window.i18n.t('batch.move_title', { count: n })
: `Move ${n} item${n !== 1 ? 's' : ''}`;
const titleText = this._t('batch.move_title', { count: n })
|| `Move ${n} item${n !== 1 ? 's' : ''}`;
dialogHeader.innerHTML = `<i class="fas fa-arrows-alt" style="color:#ff5e3a"></i> <span>${titleText}</span>`;
// Load folders, excluding selected folder IDs
const excludeIds = items.filter(i => i.type === 'folder').map(i => i.id);
await contextMenus.loadAllFolders(excludeIds[0] || null, 'batch');
dialog.style.display = 'flex';
},
@@ -377,7 +416,7 @@ const multiSelect = {
window.ui.showNotification('Preparing download', 'Creating ZIP archive...');
try {
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
const response = await fetch('/api/batch/download', {
@@ -386,12 +425,10 @@ const multiSelect = {
body: JSON.stringify({ file_ids: fileIds, folder_ids: folderIds })
});
if (!response.ok) {
throw new Error(`Server returned ${response.status}`);
}
if (!response.ok) throw new Error(`Server returned ${response.status}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `oxicloud-download-${Date.now()}.zip`;
@@ -405,61 +442,70 @@ const multiSelect = {
}
},
/** Batch add to favorites */
async batchFavorites() {
const items = this.items;
if (items.length === 0 || !window.favorites) return;
let added = 0;
for (const item of items) {
const alreadyFav = window.favorites.isFavorite(item.id, item.type);
if (!alreadyFav) {
await window.favorites.addToFavorites(item.id, item.name, item.type, item.parentId);
added++;
}
}
this.clear();
if (typeof window.loadFiles === 'function') window.loadFiles();
if (added > 0) {
window.ui.showNotification(
this._t('favorites.add') || 'Added to favorites',
`${added} item${added !== 1 ? 's' : ''} added to favorites`
);
} else {
window.ui.showNotification(
this._t('favorites.add') || 'Favorites',
'All selected items are already favorites'
);
}
},
// ── Initialization ──────────────────────────────────────
init() {
// Inject the select-all checkbox into the list header
// Wire the initial select-all checkbox
this._injectListHeaderCheckbox();
// Override the deselect-on-empty-area handler to also clear our state
// Global deselect on empty-area click
this._hookGlobalDeselect();
// Hook into the move dialog confirm to handle batch mode
// (handled in contextMenus.js — moveDialogMode === 'batch')
// Keyboard shortcut: Ctrl+A to select all, Escape to clear
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// Don't trigger when inside an input/textarea/modal
if (e.target.closest('input, textarea, [contenteditable], .rename-dialog, .share-dialog, .confirm-dialog')) return;
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
// Only when in file view (not favorites, trash etc.)
const grid = document.getElementById('files-grid');
if (grid && grid.closest('.files-container')) {
e.preventDefault();
this.selectAll();
}
}
if (e.key === 'Escape' && this.hasSelection) {
this.clear();
}
if (e.key === 'Delete' && this.hasSelection) {
this.batchDelete();
}
if (e.key === 'Escape' && this.hasSelection) this.clear();
if (e.key === 'Delete' && this.hasSelection) this.batchDelete();
});
},
/** Inject a checkbox into the list-header (or wire existing one) */
_injectListHeaderCheckbox() {
const cb = document.getElementById('select-all-checkbox');
if (!cb) return;
cb.addEventListener('change', () => {
this.toggleAll();
});
cb.addEventListener('change', () => this.toggleAll());
},
/** Override global click deselect to also clear our internal state */
_hookGlobalDeselect() {
document.addEventListener('click', (e) => {
// Don't deselect if clicking on batch bar, context menu, modal, or any file item
if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return;
if (this.hasSelection) {
this.clear();
}
if (e.target.closest('.file-card, .file-item, .context-menu, .batch-action-bar, .list-header.selection-mode, .about-modal, .rename-dialog, .share-dialog, .confirm-dialog, .modal-overlay, input, button')) return;
if (this.hasSelection) this.clear();
});
}
};