diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-02 21:21:15 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-02 21:21:15 +0200 |
| commit | 9a81c96b5a3941898f99665335ad679ff2105666 (patch) | |
| tree | 75c8dbf1b059dee8ff67e37a7cc3e9cb4dfd298d | |
| parent | baedca20a4c50845525a7490cd8bf513c6785d69 (diff) | |
newOS4 webdav path fix + WebX11 resilience package (2026-10-02)
Root cause fixes (mindtech incident):
- webdav.js: _normalizeRemotePath strips full URLs/space prefixes, applied
to all request entry points + _absoluteUrl for MOVE/COPY Destinations
- _ensureDir: __list__ existence fast-path (kills MKCOL 409 storms)
- dashboard-files.js: updateFilesJson parses __list__ autoindex (no more
'#' entries), ensureSpaceExists only re-inits on genuine 404
- 20-test regression suite: scripts/test-webdav-paths.mjs
Resilience:
- dav-auth-check.py: server-side WebDAV write auth microservice (nginx
auth_request on 8904) — anonymous/wrong-token writes now 401
- fail2ban-telegram.py: ban alerts + auto-unban for known space users
- backup-spaces.sh: daily hardlink snapshots, 14-day retention
- space-health-check.py: junk dirs / myfiles.json / version drift / session
sanity → Telegram alerts
- dashboard-files.js: visible 503 error panel with Retry + auto-retry
- v4.6.16: main.html + module-loader LATEST_VER aligned (was drifted)
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/ext/webdav.js | 111 | ||||
| -rw-r--r-- | scripts/test-webdav-paths.mjs | 136 |
4 files changed, 225 insertions, 30 deletions
@@ -1345,13 +1345,13 @@ </script> <!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix --> -<script src="modules/core/core-bundle.js?v=v4.6.15"></script> +<script src="modules/core/core-bundle.js?v=v4.6.16"></script> <!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules --> -<script src="modules/core/module-loader.js?v=v4.6.15"></script> +<script src="modules/core/module-loader.js?v=v4.6.16"></script> <!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work --> -<script src="modules/ext/critical-ext-bundle.js?v=v4.6.15"></script> +<script src="modules/ext/critical-ext-bundle.js?v=v4.6.16"></script> <script> // ═══ ONE TRUE VERSION SOURCE ═══ diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js index 5a62884..16723ad 100644 --- a/modules/core/module-loader.js +++ b/modules/core/module-loader.js @@ -4,7 +4,7 @@ // Core module bundle loaded synchronously in main.html. // This loader only handles version cache-busting, splash, terminal init, and optional ext modules. - const LATEST_VER = 'v4.6.10'; + const LATEST_VER = 'v4.6.16'; const CACHE = '?v=' + LATEST_VER; const stored = localStorage.getItem('webx11_cache_ver'); if (stored !== LATEST_VER) { diff --git a/modules/ext/webdav.js b/modules/ext/webdav.js index a84eb12..5bcc4ee 100644 --- a/modules/ext/webdav.js +++ b/modules/ext/webdav.js @@ -167,6 +167,50 @@ class WebDAVModule { return { 'Authorization': 'Basic ' + token }; } + _normalizeRemotePath(path) { + // v4.6.16: Paths can arrive as VFS-relative ('file.txt'), space-relative + // ('/file.txt'), space-prefixed ('/spaces/<user>/file.txt') or even a + // full URL ('https://host/spaces/<user>/file.txt'). Blindly joining + // such paths to the config URL previously created literal 'https:/' + // and nested 'spaces/<user>/' junk directories inside the space. + if (!path) return '/'; + let p = String(path).trim(); + if (!p) return '/'; + // Strip full URL: keep only the pathname (and drop any query/hash) + if (/^https?:\/\//i.test(p)) { + try { + const u = new URL(p); + p = u.pathname; + } catch (e) { + p = p.replace(/^[a-z][a-z0-9+.-]*:\/\/[^/]+/i, ''); + } + } + // Strip the space prefix if present ('/spaces/<user>/...', 'spaces/<user>/...') + const spaceName = this._resolveSpaceName(); + if (spaceName) { + const re = new RegExp('^\\/*spaces/' + spaceName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?=/|$)'); + const m = p.match(re); + if (m) p = p.substring(m[0].length); + } + p = p.split('?')[0].split('#')[0]; + if (!p.startsWith('/')) p = '/' + p; + // Collapse duplicate slashes and resolve '..' / '.' segments + const out = []; + for (const seg of p.split('/')) { + if (seg === '' || seg === '.') continue; + if (seg === '..') { out.pop(); continue; } + out.push(seg); + } + return '/' + out.join('/'); + } + + _absoluteUrl(remotePath) { + // Destination headers (MOVE/COPY) must be absolute URIs — build from + // the configured base URL + normalized path. + const baseUrl = (this.config && this.config.url) ? this.config.url.replace(/\/+$/, '') : ''; + return baseUrl + this._normalizeRemotePath(remotePath); + } + async _webdavRequest(method, path, options = {}) { if (!this.config || !this.config.url) { throw new Error('WebDAV not configured. Use webdav-config first.'); @@ -176,7 +220,7 @@ class WebDAVModule { await this._resolvePassword(); } const baseUrl = this.config.url.replace(/\/+$/, ''); - const urlPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; + const urlPath = this._normalizeRemotePath(path); const url = baseUrl + urlPath; const headers = { @@ -225,7 +269,7 @@ class WebDAVModule { host = u.origin; } catch (e) {} } - const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : '/'; + const cleanPath = this._normalizeRemotePath(path); // Avoid double slash when path starts with / const listUrl = host + '/spaces/' + spaceName + '/__list__' + (cleanPath === '/' ? '' : cleanPath.replace(/\/{2,}/g, '/')); try { @@ -344,26 +388,37 @@ class WebDAVModule { } async _ensureDir(remotePath) { - // Try MKCOL — ignore 405 (already exists) - const response = await this._webdavRequest('MKCOL', remotePath); - if (response.ok || response.status === 405 || response.status === 301 || response.status === 302) return true; - if (response.status === 409) { - // Parent missing — create each missing component once, bottom-up. - // (Previously this recursed + retried + looped, producing an MKCOL - // storm of 6-8 requests per call that delayed uploads.) - const parts = remotePath.replace(/\/+$/, '').split('/').filter(Boolean); - let current = ''; - let created = false; - for (const part of parts) { - current += '/' + part; - try { - const attempt = await this._webdavRequest('MKCOL', current); - if (attempt.ok || attempt.status === 405) created = true; - } catch (e) { /* ignore — component may exist */ } - } - return created; + // v4.6.16: normalize FIRST — absolute/URL/junk paths previously built + // MKCOL chains into literal 'https:/' and nested 'spaces/<user>/' dirs. + const path = this._normalizeRemotePath(remotePath).replace(/\/+$/, ''); + if (!path || path === '/') return true; + const segments = path.split('/').filter(Boolean); + // Fast path: probe with cheap __list__ GETs for the deepest existing + // ancestor, then MKCOL only the missing tail. nginx returns 409 for + // BOTH 'already exists' and 'parent missing', so a blind MKCOL walk + // fires 2-8 wasted 409 requests per call even when nothing is missing. + let deepest = 0; + for (let i = segments.length - 1; i >= 0; i--) { + const prefix = '/' + segments.slice(0, i + 1).join('/'); + try { + await this._listDir(prefix); + deepest = i + 1; + break; + } catch (e) { /* not present — try shallower */ } + } + if (deepest === segments.length) return true; + // Create each missing component once, bottom-up — seed with the + // existing prefix so paths stay absolute. + let current = deepest > 0 ? '/' + segments.slice(0, deepest).join('/') : ''; + let created = false; + for (let i = deepest; i < segments.length; i++) { + current += '/' + segments[i]; + try { + const attempt = await this._webdavRequest('MKCOL', current); + if (attempt.ok || attempt.status === 405 || attempt.status === 301 || attempt.status === 302) created = true; + } catch (e) { /* ignore — component may exist */ } } - return false; + return created; } async _deleteFile(remotePath) { @@ -881,8 +936,12 @@ class WebDAVModule { return; } try { + // Destination must be an absolute URI — build it from the + // configured base URL + normalized path so junk prefixes + // (full URLs, '/spaces/<user>/...') can't smuggle through. + const dst = self._absoluteUrl(args[1]); const resp = await self._webdavRequest('MOVE', args[0], { - headers: { 'Destination': args[1] } + headers: { 'Destination': dst } }); if (resp.ok || resp.status === 201 || resp.status === 204) { self.terminal.print('Renamed: ' + args[0] + ' → ' + args[1], 'success'); @@ -1343,7 +1402,7 @@ class WebDAVModule { const parentPath = path.replace(/\/+$/, '') || ''; const oldFull = parentPath + '/' + entry.name; const newFull = parentPath + '/' + newName; - self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) + self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': self._absoluteUrl(newFull) } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) { self.terminal.print('Renamed: ' + entry.name + ' → ' + newName, 'success'); @@ -1477,7 +1536,7 @@ class WebDAVModule { names.forEach(function(name) { const oldFull = (path.replace(/\/+$/, '') || '') + '/' + name; const newFull = target + '/' + name; - self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }) + self._webdavRequest('MOVE', oldFull, { headers: { 'Destination': self._absoluteUrl(newFull) } }) .then(function(r) { if (r.ok || r.status === 201 || r.status === 204) self.terminal.print('Moved: ' + name, 'success'); else self.terminal.print('Move failed: ' + name + ' (HTTP ' + r.status + ')', 'error'); @@ -1544,7 +1603,7 @@ class WebDAVModule { if (newName && newName !== entry.name) { var oldFull = (currentPath.replace(/\/+$/, '') || '') + '/' + entry.name; var newFull = (currentPath.replace(/\/+$/, '') || '') + '/' + newName; - this._webdavRequest('MOVE', oldFull, { headers: { 'Destination': newFull } }).then(function(r) { + this._webdavRequest('MOVE', oldFull, { headers: { 'Destination': this._absoluteUrl(newFull) } }).then(function(r) { if (r.ok || r.status === 201 || r.status === 204) { this.terminal.print('Renamed: ' + entry.name + ' → ' + newName, 'success'); this.openWebdavBrowser(currentPath); @@ -1729,7 +1788,7 @@ class WebDAVModule { // XHR PUT so we get upload progress events (fetch has none) await new Promise(function(resolve, reject) { const baseUrl = self.config.url.replace(/\/+$/, ''); - const urlPath = remotePath.startsWith('/') ? remotePath : '/' + remotePath; + const urlPath = self._normalizeRemotePath(remotePath); const xhr = new XMLHttpRequest(); xhr.open('PUT', baseUrl + urlPath, true); const auth = self._getAuthHeader(); diff --git a/scripts/test-webdav-paths.mjs b/scripts/test-webdav-paths.mjs new file mode 100644 index 0000000..a8340c0 --- /dev/null +++ b/scripts/test-webdav-paths.mjs @@ -0,0 +1,136 @@ +// v4.6.16 regression test: WebDAV remote-path normalization. +// Reproduces the mindtech incident: AI passed full URLs / absolute +// '/spaces/<user>/' paths which got blindly joined onto the config URL, +// creating literal 'https:/' and nested 'spaces/<user>/' junk dirs. +// Run: node scripts/test-webdav-paths.mjs (repo root = spaces/newos4) + +// Run: node scripts/test-webdav-paths.mjs (repo root = spaces/newos4) + +import { readFileSync } from 'fs'; + +const src = readFileSync(new URL('../modules/ext/webdav.js', import.meta.url), 'utf8'); + +class TerminalStub { + print() {} +} + +// Mirrors ModuleManager.createModuleClass(): extract the first top-level class. +const cleanCode = src.replace(/^\s*(?:\/\/[^\n]*\n|\/\*[\s\S]*?\*\/\s*)*\s*/, ''); +const classMatch = cleanCode.match(/class\s+(\w+)\s+(extends\s+\S+)?\s*\{/); +if (!classMatch) throw new Error('no module class found in webdav.js'); +const WebDAVModule = new Function('window', 'localStorage', 'fetch', 'XMLHttpRequest', 'URL', 'crypto', + 'return ' + cleanCode + '\nreturn ' + classMatch[1])( + { location: { pathname: '/spaces/mindtech/newos4/main.html' } }, + { getItem: () => null, setItem: () => {} }, + async () => { throw new Error('network disabled in test'); }, + function () { this.open = () => {}; this.setRequestHeader = () => {}; this.send = () => {}; }, + URL, undefined); + +function makeMod() { + const m = new WebDAVModule(new TerminalStub()); + m.config = { url: 'https://webx11.duckdns.org/spaces/mindtech/', username: 'mindtech', password: 'tok' }; + return m; +} + +let pass = 0, fail = 0; +function check(name, cond, got) { + if (cond) { pass++; console.log(' ok - ' + name); } + else { fail++; console.log(' FAIL - ' + name + (got !== undefined ? ' got: ' + JSON.stringify(got) : '')); } +} + +// --- _normalizeRemotePath --- +{ + const m = makeMod(); + const cases = [ + // [input, expected] + [null, '/'], + ['', '/'], + ['file.txt', '/file.txt'], + ['/file.txt', '/file.txt'], + // the mindtech incident shapes: + ['https://webx11.duckdns.org/spaces/mindtech/thaimassage-kiefersfelden/index-traditional.html', + '/thaimassage-kiefersfelden/index-traditional.html'], + ['/spaces/mindtech/thaimassage-kiefersfelden/index-professional.html', + '/thaimassage-kiefersfelden/index-professional.html'], + ['https://webx11.duckdns.org/spaces/mindtech', '/'], + ['spaces/mindtech/skills/navigating-the-terminal.json', '/skills/navigating-the-terminal.json'], + // relative + traversal + dupes: + ['./a//b/../c.txt', '/a/c.txt'], + ['/a/b/../../d.txt', '/d.txt'], + ['/skills/', '/skills'], + ['backups', '/backups'], + // foreign space stays intact (only own space prefix is stripped): + ['/spaces/otheruser/file.txt', '/spaces/otheruser/file.txt'], + ['https://evil.example.com/spaces/mindtech/x?query=1#frag', '/x'], + ]; + for (const [input, expected] of cases) { + const got = m._normalizeRemotePath(input); + check(`normalize(${JSON.stringify(input)}) => ${expected}`, got === expected, got); + } +} + +// --- _absoluteUrl (MOVE/COPY Destination) --- +{ + const m = makeMod(); + const got = m._absoluteUrl('https://webx11.duckdns.org/spaces/mindtech/a/b.html'); + check('_absoluteUrl strips to full absolute URI', + got === 'https://webx11.duckdns.org/spaces/mindtech/a/b.html', got); + const got2 = m._absoluteUrl('c.html'); + check('_absoluteUrl builds from config base', + got2 === 'https://webx11.duckdns.org/spaces/mindtech/c.html', got2); +} + +// --- _ensureDir normalizes before MKCOL walk --- +{ + const m = makeMod(); + const mkcols = []; + m._webdavRequest = async (method, path) => { + if (method === 'MKCOL') mkcols.push(path); + return { ok: true, status: 201 }; + }; + m._listDir = async () => { throw new Error('ENOENT'); }; + await m._ensureDir('https://webx11.duckdns.org/spaces/mindtech/thaimassage-kiefersfelden'); + check('_ensureDir MKCOLs normalized path (no https:/ junk)', + mkcols.length === 1 && mkcols[0] === '/thaimassage-kiefersfelden', mkcols); + + // Existing dir fast-path: __list__ probe succeeds -> no MKCOL at all + const m2 = makeMod(); + const mkcols2 = []; + m2._listDir = async (p) => { if (p === '/skills') return []; throw new Error('ENOENT'); }; + m2._webdavRequest = async (method, path) => { if (method === 'MKCOL') mkcols2.push(path); return { ok: true, status: 201 }; }; + await m2._ensureDir('/skills'); + check('_ensureDir skips MKCOL when dir already exists', mkcols2.length === 0, mkcols2); + + // Partial existing: /backups exists, /backups/2026 does not + const m3 = makeMod(); + const mkcols3 = []; + m3._listDir = async (p) => { if (p === '/backups') return []; throw new Error('ENOENT'); }; + m3._webdavRequest = async (method, path) => { if (method === 'MKCOL') mkcols3.push(path); return { ok: true, status: 201 }; }; + await m3._ensureDir('/backups/2026'); + check('_ensureDir MKCOLs only missing tail', mkcols3.length === 1 && mkcols3[0] === '/backups/2026', mkcols3); +} + +// --- PUT via _webdavRequest hits normalized URL --- +{ + // The class closes over the injected fetch param — rebuild with a + // recording fetch so we can assert the request URL. + let requested = null; + const recordFetch = async (url) => { requested = url; return { ok: true, status: 201 }; }; + const WebDAVModule2 = new Function('window', 'localStorage', 'fetch', 'XMLHttpRequest', 'URL', 'crypto', + 'return ' + cleanCode + '\nreturn ' + classMatch[1])( + { location: { pathname: '/spaces/mindtech/newos4/main.html' } }, + { getItem: () => null, setItem: () => {} }, + recordFetch, + function () { this.open = () => {}; this.setRequestHeader = () => {}; this.send = () => {}; }, + URL, undefined); + const m = new WebDAVModule2(new TerminalStub()); + m.config = { url: 'https://webx11.duckdns.org/spaces/mindtech/', username: 'mindtech', password: 'tok' }; + m._resolvePassword = async () => {}; + m._getAuthHeader = () => ({}); + await m._webdavRequest('PUT', 'https://webx11.duckdns.org/spaces/mindtech/site/index.html', { body: 'x' }); + check('PUT goes to normalized URL', + requested === 'https://webx11.duckdns.org/spaces/mindtech/site/index.html', requested); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);
\ No newline at end of file |