fix(review): open-redirect, btoa UTF-8 safety, honest duplicate-mode txn
Pre-release review findings on dev -> main:
- Return-URL open-redirect (MED): a base64 returnurl like "/\evil.com"
passed the root-relative allow-list (raw[1] is "\", not "/"), and a
browser normalises leading "/\" to "//", yielding a protocol-relative
redirect off-site. Reject any backslash or control char in the decoded
return URL outright — legitimate Joomla admin URLs never contain them.
- installer-backup.js btoa() (LOW): btoa() throws on non-Latin1 input
(e.g. a UTF-8 filter value in the list query string), and the throw was
uncaught after preventDefault(), leaving the toolbar button dead. Build
the redirect (UTF-8-safe base64) BEFORE swallowing the click; if it
still fails, bail without preventing so Joomla proceeds normally.
- Snapshot duplicate-mode transaction (HIGH): restore() wrapped duplicate
mode in transactionStart()/Rollback(), but duplicate mode writes via
Joomla Table API whose Nested tables (categories/tags) issue LOCK
TABLES, which implicitly COMMITS the open transaction in MySQL — so the
rollback promise was illusory and a mid-inject failure left a slave
half-updated. Run only the raw-DB modes (replace/create/merge) inside
the transaction; duplicate mode runs outside it, matching its existing
per-item defensive design (bad item skipped + logged, never fatal).
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
style(runbackup): drop max-width/margin so the card spans full width
The backup screen was centred in a 720px column; unset max-width and the
auto margins so it fills the available width. Keeps the horizontal padding.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
fix: reliably intercept extension update/uninstall + polish backup screen
The full-screen pre-action backup did not fire for extension
updates/uninstalls because Joomla 6 renders the com_installer toolbar
Update/Uninstall buttons as `<button task="update.update">` web
components, not inline onclick="Joomla.submitbutton(...)". Wrapping
Joomla.submitbutton never caught them.
installer-backup.js now uses a capture-phase document click listener on
any `[task]` element matching update.update / manage.remove(/uninstall),
so it runs before the toolbar web component. It captures the checked
cid[] selection, runs the full-screen backup, and on return restores the
selection, sets the boxchecked list-guard field, and re-fires the real
toolbar button with __msbResuming set so it is not re-intercepted.
Also polishes the runbackup screen: the progress bar is now a full-bleed
strip along the bottom edge of the card (edge-to-edge, clipped to the
rounded corners) instead of a boxed bar crowding the status text, and it
is coloured from the Atum template CSS custom properties
(--template-link-color / --template-special-color / --template-success-color
/ --template-danger-color) with Bootstrap and literal fallbacks.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
@
After the full-screen pre-update backup returns to the Joomla Update
confirm page (layout=update&is_backed_up=1), inject a small script that
ticks Joomla's 'I have taken a backup' checkbox (#joomlaupdate-confirm-
backup) and clicks the Install button (.emptystate-btnadd) so the update
continues automatically — no second manual click. Injected via
onBeforeCompileHead, super-user only, gated on backup_before_update.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
The 'Install the update' button posts to
option=com_joomlaupdate&layout=update (Joomla's confirm/updating page),
carrying only layout=update -- no view, no task. The redirect matched
view=update / update.install, so it never fired on the Joomla Update page.
Match layout=update (plus view=update and legacy update.install), and
return the browser to the same layout flagged is_backed_up=1.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Finishes the deferred 'duplicate' conflict mode. Inserts snapshot content
as brand-new records with remapped IDs, leaving existing content
untouched — for master->slave 'push as new copies'.
- Uses Joomla's Table API (bootComponent MVCFactory) for Article,
Category and Module so assets, UCM entries, category/tag nested-sets
and alias uniqueness are handled by core rather than hand-rolled.
- Remaps category parent_id (parents first), article catid, module menu
assignments, and custom-field-value item_id; re-features articles.
- Tags are reused/created by title (resolveTagIds) and assigned via the
article table's newTags so UCM/tag-map stay correct.
- uniqueAlias() avoids alias collisions per scope.
- DEFENSIVE: every item is wrapped in try/catch — a bad item is skipped
and logged, never fatal — so an untested/partial import degrades
gracefully instead of corrupting content.
NOTE: still needs a real test pass on a dev site before relying on it.
Refs #237
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Adds the first increment of snapshot transfer:
- SnapshotPortable engine: package a snapshot into a portable .msbsnap
(zip of manifest.json + snapshot.json + sha256), and ingest one back
(from a zip upload or an inline payload) by materialising a local
snapshot record.
- Admin: per-row 'Download portable snapshot' + an Import modal
(upload .msbsnap) on the Snapshots page.
- API: POST /snapshot/inject — master pushes a snapshot payload into a
slave's Web Services API; gated by a new 'Allow Snapshot Injection'
option. Route added to the webservices plugin.
- Restore engine: new 'create' mode (skip existing, insert only new) and
mode-name normalisation (overwrite->replace, create, duplicate). The
'duplicate' (insert-as-new-with-remapped-IDs) mode returns a clear
'not yet available' — deferred to a dedicated, tested pass.
- Options: 'Snapshot Transfer' fieldset — default conflict mode +
allow-inject toggle. Language keys added (en-GB fallback).
Reuses the existing mokosuitebackup.snapshot.manage ACL. NOTE: untested
end-to-end (no dev build this session) — verify download/import/inject
on a dev build.
Refs #237
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Extend the full-screen backup screen beyond core Joomla updates:
- Extension updates/uninstalls (com_installer): update.update and
manage.remove are POST + checkToken with a checked cid[] list, so a
server-side GET redirect can't resume them. New client-side interceptor
(installer-backup.js, injected via onBeforeCompileHead on the update and
manage views) wraps Joomla.submitbutton, stores the selection, sends the
browser to view=runbackup, and on return restores the selection and
re-submits the real POST form. Gated by backup_before_update /
backup_before_uninstall, super-user only, skipped within the throttle
window.
- runbackup: on manual 'Backup Now' completion, show a 'View backup
record' button (links to view=backup&id=<record_id>); not shown for the
pre-update/uninstall flow (which hands back to Joomla). SteppedBackup
Engine now returns record_id in its step result.
- runbackup arms the pre-action throttles on success (ajax.preupdateAck,
now arming both update+uninstall keys) so the following server-side
onExtensionBefore(Update|Uninstall) backup is skipped (no duplicate).
- Plugin manifest re-registers media/js.
NOTE: untested end-to-end (no dev build available this session) — verify
the com_installer resume flow on a dev build before relying on it.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
The redirect only matched task=update.install, which Joomla 4/5/6 do not
use. Their 'Install the update' flow server-side-redirects to the updating
page (view=update), which then extracts the downloaded package from
JavaScript (AJAX to com_joomlaupdate/extract.php). So the view=update page
LOAD is the last server-side moment before any files change.
Intercept that page load (in onAfterRoute, before com_joomlaupdate renders
it), redirect to the full-screen backup with a returnurl back to
view=update&is_backed_up=1, then the browser returns and the JS extraction
runs. update.finalise is deliberately NOT intercepted -- by then the files
are already extracted, too late for a pre-update backup. Legacy
task=update.install (Joomla 3) still handled.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Joomla logs a failed child extension but still runs the package
postflight, so the 'installed successfully' message could appear on a
broken install. In postflight, before the success/next-steps messages:
- verify every bundled child declared in the package manifest actually
registered in #__extensions (match element+type, and folder=group for
plugins), and
- verify the component's schema tables (parsed dynamically from the
installed install.mysql.sql) exist in the DB.
If anything is missing, enqueue an error listing it and RETURN before the
success message. Fail-open (try/catch -> nothing missing) so a transient
manifest/DB/IO glitch can't fail a good install. Unconditional
housekeeping (download-key restore, menu fixes) still runs regardless.
Names escaped with htmlspecialchars; table/child lists derived
dynamically, never hard-coded.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
The pre-update return URL is assigned to window.location.href, so a
javascript:/data: URI (empty host) would have passed the host-only check
and executed. Require an absolute http(s) same-host URL or a single-slash
root-relative path; reject javascript:/data:/vbscript: and protocol-relative
//host.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Replace the popup-modal pre-update approach with a dedicated full-page
backup screen, modelled on Akeeba Backup-on-Update, and fix the white
screen caused by running a backup synchronously inside the update request.
- New view=runbackup (View/Runbackup + tmpl/runbackup): full-screen
progress page that auto-starts the stepped backup (ajax.init -> loop
ajax.step), then redirects to a validated returnurl on completion or
shows a completion/retry panel. Auto-resolved by MVCFactory.
- System plugin: on com_joomlaupdate task=update.install (when
backup_before_update is on), redirect to the full-screen backup screen
with returnurl back to update.install&is_backed_up=1; on return, arm the
throttle so onExtensionBeforeUpdate does not run a duplicate backup. No
synchronous backup in the update request.
- Dashboard 'Backup Now' navigates to the full-screen screen instead of
the inline modal; the inline modal + stepped JS are removed.
- Retire the popup modal: delete media/js/update-backup.js, remove the
plugin <media> element and the preupdate_auto_intercept param.
- Dashboard: warn when Global Config live_site is empty (CLI/scheduled
backup naming + URL generation depend on it).
Refs #196
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
The pre-update/uninstall backup runs synchronously inside the extension
update request. Without raising limits, a large site exceeds
max_execution_time/memory_limit mid-backup and the update page
white-screens. Raise set_time_limit(0)/memory_limit/ignore_user_abort
like the web-cron path.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
Joomla's console fills $_SERVER['HTTP_HOST'] with the reserved sentinel
'joomla.invalid', which the [HOST] placeholder resolver used verbatim
because its unusable-host check only knew about empty/localhost. Treat
'joomla.invalid' as unusable too, falling back to live_site then the
system hostname, so CLI/console/scheduled backups get a real name.
Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i