the folder param is relative to the base, and stops leaking an email

`?media=/officerdev/data/tatudoemcampo@gmail.com/FofosWebsite/media/originals/ABOUT+US`
became `?media=ABOUT+US`.

the length is the smaller half. the absolute form put THE ACCOUNT'S EMAIL ADDRESS in
every link — so a URL sent to a colleague, or pasted into a chat, carried the owner's
address and the shape of their disk with it. relative, it says nothing about the
machine or who owns it.

it also makes the jail hold BY CONSTRUCTION rather than by comparison. the value is
joined to `basePath`, so there is no "outside" left to check for — only `..` could
climb out, and a `..` segment is refused outright. the previous version compared a
caller-supplied absolute path against a prefix, which is the weaker shape and the one
that goes wrong when somebody later "simplifies" it.

round-trip and tampering both exercised against the real base path before committing:
base, a folder with a space, a nested folder with a non-ascii name, then `../../etc`,
`GALLERY/../../..` and `/etc/passwd` — refused, refused, contained.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 09:11:45 +01:00
co-authored by Claude Opus 5
parent 0ea4b822ae
commit 321cac2e80
@@ -86,17 +86,22 @@ export const useFileBrowserApp = (
Three sources, mutually exclusive by construction: the pathname for /files, a named query param for Three sources, mutually exclusive by construction: the pathname for /files, a named query param for
an embedded browser, and component state for an ordinary panel. an embedded browser, and component state for an ordinary panel.
A param naming a folder OUTSIDE the jail is ignored rather than obeyed — it arrives from a URL ── The param is RELATIVE to the base, and that is not only for tidiness ──
somebody may have edited, and `basePath` is the whole promise of an embedded browser.
An absolute path put the account's own email in every link — `?media=/officerdev/data/
someone@gmail.com/...` — so a URL sent to a colleague carried the owner's address with it. Relative,
the same folder is `?media=ABOUT+US`: shorter, and it says nothing about the machine or the account.
It is also what makes the jail hold by construction rather than by comparison. The value is JOINED
to `basePath`, so there is no "outside" to check for — only `..` could climb out, and a segment of
`..` is refused outright.
*/ */
const paramPath = pathParam ? searchParams.get(pathParam) : null; const paramRelative = pathParam ? searchParams.get(pathParam) : null;
const currentPath = urlPath const paramPath =
? folderFromPathname(location.pathname) paramRelative && !paramRelative.split('/').includes('..')
: pathParam ? `${basePath.replace(/\/$/, '')}/${paramRelative.replace(/^\/+/, '')}`
? paramPath && paramPath.startsWith(basePath) : null;
? paramPath const currentPath = urlPath ? folderFromPathname(location.pathname) : pathParam ? (paramPath ?? basePath) : localPath;
: basePath
: localPath;
const setCurrentPath = (path: string) => { const setCurrentPath = (path: string) => {
// Leaving a folder closes what was open in it. A pane shows a file from the folder you were in, so // Leaving a folder closes what was open in it. A pane shows a file from the folder you were in, so
// carrying it into the next one leaves the two disagreeing — the listing says one place, the pane // carrying it into the next one leaves the two disagreeing — the listing says one place, the pane
@@ -109,8 +114,9 @@ export const useFileBrowserApp = (
const next = new URLSearchParams(withoutViewerParams(prev.toString())); const next = new URLSearchParams(withoutViewerParams(prev.toString()));
// The base folder is the ABSENCE of the param, so a link to the top of an embedded browser is // The base folder is the ABSENCE of the param, so a link to the top of an embedded browser is
// the plain screen URL and nothing accumulates for having gone nowhere. // the plain screen URL and nothing accumulates for having gone nowhere.
if (path === basePath) next.delete(pathParam); const relative = path.startsWith(basePath) ? path.slice(basePath.replace(/\/$/, '').length + 1) : '';
else next.set(pathParam, path); if (!relative) next.delete(pathParam);
else next.set(pathParam, relative);
return next; return next;
}, },
// Browsing folders is not navigation anybody wants twenty back-button presses of. // Browsing folders is not navigation anybody wants twenty back-button presses of.