Skip to content

Add deeplink for importing a site from a backup URL - #3618

Open
borkweb wants to merge 5 commits into
trunkfrom
feature/import-backup
Open

Add deeplink for importing a site from a backup URL#3618
borkweb wants to merge 5 commits into
trunkfrom
feature/import-backup

Conversation

@borkweb

@borkweb borkweb commented May 26, 2026

Copy link
Copy Markdown
Member

Summary

Adds a wp-studio://import-backup?url=<url> deeplink that downloads a backup over https, validates it, and opens the Add Site modal at the import step with the file ready to go. Optional &name=<filename> overrides the filename derived from the URL.

Why

There was no way for an external site (WordPress.com, a third-party host) to hand a user into Studio with a backup queued for import — they had to download it, open Studio, and re-select the file by hand. The deeplink is triggerable from any web page, email, or IM, so the URL is attacker-controlled, and backups run PHP via Playground on import — hence the defensive URL handling below.

How

  • Main process (handlers/import-backup.ts): parses the URL without re-decoding (URLSearchParams.get() already decodes it; a second decode would allow double-encoding past a future allowlist — the CVE-2021-41773 shape), rejects non-https: schemes (blocks MITM payload swaps), downloads to a sanitized temp path, validates extension and non-empty size, then emits import-backup-from-deeplink. Failures clean up the temp file and show an error dialog.
  • Renderer: useImportBackupDeeplink feeds the file into the Add Site form and routes to /backup/create. Since the file never passes through a browser File, use-import-export.tsx takes an ImportSource = File | DeeplinkBackupFile union and passes removeBackupOnComplete so the temp download is deleted after import.

Testing

  • Valid https .zip/.tar.gz URL → Studio opens at the backup import step with the name shown.
  • &name=my-export.tar.gz → supplied name is used.
  • http:// or file:// URL → rejected with "Please check the link and try again."
  • Unsupported extension (.txt) → same error dialog.
  • Unreachable host → connection-specific error; "Open Studio Logs" works.
  • Minimized Studio → window restored/focused before the modal opens.
  • npm test -- apps/studio/src/lib/deeplink/tests/import-backup.test.ts passes.

Related issues

## Summary
Adds a new `wp-studio://import-backup?url=<url>` deeplink that downloads a backup file over https, validates it, and opens the Add Site modal pre-routed to the backup import step with the file ready to import. An optional `&name=<filename>` parameter overrides the filename derived from the URL.

## Why
Studio already supports deeplinks for OAuth, sync-connect, and blueprint-based site creation, but there was no way for an external site (e.g. WordPress.com or a third-party host) to hand a user off into Studio with a specific backup file queued for import. Users had to download the backup manually, open Studio, start an Add Site flow, and re-select the file from disk. This closes that gap so a single click can take a user from a "Download backup" link into a populated Add Site → Import from a backup flow. Because the deeplink is triggerable from any web page, email, or IM, the inbound URL is fully attacker-controlled, and the downloaded backup carries PHP plugins/themes that Studio executes via WordPress Playground after import — so the URL has to be handled defensively.

## How
The main-process handler (`apps/studio/src/lib/deeplink/handlers/import-backup.ts`) parses the URL directly — the value from `URLSearchParams.get()` is already percent-decoded, so it is deliberately not decoded a second time; re-decoding would let an attacker double-encode characters like `?` to shift the query boundary past a future allowlist check (the Apache CVE-2021-41773 shape). It rejects any scheme other than `https:` so a network MITM cannot swap the payload of a cleartext download, then downloads the backup to a sanitized path under the OS temp directory, validates the extension against `ACCEPTED_IMPORT_FILE_TYPES`, confirms the downloaded file is non-empty, and emits a new `import-backup-from-deeplink` IPC event with the path/name/size. On failure it cleans up the temp file and surfaces an error dialog with an "Open Studio Logs" shortcut; network errors get a connection-specific message.

The renderer hook `useImportBackupDeeplink` listens for that event and feeds the file reference into the Add Site form, then flips an `isDeeplinkFlow` flag so the existing navigator routing logic in `add-site/index.tsx` jumps straight to `/backup/create`.

Because the file is downloaded by the main process, it never passes through a browser `File` instance. To avoid a separate code path through `importFile`, `use-import-export.tsx` now accepts an `ImportSource = File | DeeplinkBackupFile` union and branches on `isDeeplinkBackupFile` to skip `getPathForFile` when the path is already known. For these deeplink imports it also passes `removeBackupOnComplete` so the main process deletes the downloaded temp file once the import settles, on both success and failure. `useAddSite` and the Add Site modal were updated to thread the wider type through.

## Testing
- [ ] Trigger `wp-studio://import-backup?url=<https-url-to-a-valid-.zip-or-.tar.gz>` (e.g. via `open` on macOS) and confirm Studio opens, downloads the file, and the Add Site modal opens at the "Import from a backup" step with the backup name shown.
- [ ] Trigger the same deeplink with `&name=my-export.tar.gz` and confirm the supplied name is used in the modal.
- [ ] Trigger with an `http://` or `file://` URL and confirm the import is rejected with "Please check the link and try again."
- [ ] Trigger with a URL whose path ends in `.txt` (or any extension outside `ACCEPTED_IMPORT_FILE_TYPES`) and confirm the error dialog appears with "Please check the link and try again."
- [ ] Trigger with an unreachable host (e.g. `https://does-not-exist.invalid/site.zip`) and confirm the network-specific error message appears, and that "Open Studio Logs" opens the log file.
- [ ] Trigger while Studio is minimized and confirm the window is restored and focused before the modal opens.
- [ ] `npm test -- apps/studio/src/lib/deeplink/tests/import-backup.test.ts` passes.
- [ ] `npx eslint --fix` on modified files and `npm run typecheck` are clean.
@borkweb
borkweb force-pushed the feature/import-backup branch from e37c09a to 230cc95 Compare May 26, 2026 11:38
@borkweb
borkweb marked this pull request as ready for review May 28, 2026 11:37
@lezama

lezama commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Nice defensive work on the URL handling. One gap stands out, plus a smaller follow-up — both worth closing before this lands, since an imported backup runs PHP via Playground.

1. The url host is unrestricted

The deeplink is triggerable from any web page / email / IM, but import-backup.ts accepts any https:// host. In the real flow the backup URL always points at a single known origin (the Telex export endpoint), so a host allow‑list costs almost nothing and removes the "any attacker‑chosen URL gets downloaded and queued for a PHP‑executing import" surface entirely.

const ALLOWED_BACKUP_HOSTS = [ 'telex.automattic.ai', 'telex.localhost' ];

// exact, case-insensitive match — not endsWith, so `telex.automattic.ai.evil.com`
// and `eviltelex.automattic.ai` are both rejected
if ( ! ALLOWED_BACKUP_HOSTS.includes( parsedUrl.hostname.toLowerCase() ) ) {
    throw new Error( `Backup host "${ parsedUrl.hostname }" is not allowed.` );
}

(Open question for you: the PR description mentions "a third‑party host" as an intended source — if that's really desired, the allow‑list conflicts with it and we should talk through the threat model. If Telex is the only producer, the allow‑list is the safer default.)

2. The https‑only check can be bypassed by a redirect

download() uses follow-redirects, which by default will follow an https → http redirect (it only strips confidential headers on downgrade, it doesn't block it). So https://allowed/x.zip302 Location: http://…/payload.zip is fetched over cleartext, re‑opening exactly the MITM payload‑swap window the protocol !== 'https:' guard is meant to close. The initial‑URL check isn't enough.

Cleanest fix is in download() itself (also hardens the blueprint deeplink, and never affects http:‑initiated downloads):

const requestOptions = urlProtocol === 'https:'
    ? { beforeRedirect: ( options: { protocol?: string } ) => {
          if ( options.protocol !== 'https:' ) {
              throw new Error( `Refusing redirect to insecure protocol "${ options.protocol }".` );
          }
      } }
    : {};
httpModule.get( url, requestOptions, ( response ) => { /* … */ } );

I have both changes implemented locally with the test file updated (allow‑list happy/look‑alike/rejected‑host cases added) — 14 tests passing, lint clean. Happy to push them to this branch or hand them over, whichever you prefer.

Smaller notes (non‑blocking)

  • Silent drop when a site is processing: in use-import-backup-deeplink.ts, if isAnySiteProcessing the handler returns, but the backup is already downloaded — orphaned temp file + no user feedback. Worth a toast and/or main‑side cleanup.
  • Abandoned‑import temp leak: the temp is only removed via removeBackupOnComplete on import completion. If the user closes the modal without importing, the (potentially large) zip lingers in wp-studio-imports/.
  • Residual after the allow‑list: an open redirect on an allowed host could still bounce to an untrusted origin; validating the redirect target host (not just the protocol) would close that too.
@lezama

lezama commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Pushed both changes to this branch in 835c349 (host allow-list + the download() redirect-downgrade guard, with the test updates). 14 tests passing, lint clean.

Heads-up: pushing required the branch to be current with trunk, so that commit sits on top of a clean trunk merge (no conflicts) — feel free to re-sync however you prefer. The non-blocking notes above (silent drop on isAnySiteProcessing, abandoned-import temp leak, redirect-target host validation) I left for you to decide on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@wpmobilebot

wpmobilebot commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

📊 Performance Test Results

Comparing 9e64fd2 vs trunk

app-size

Metric trunk 9e64fd2 Diff Change
App Size (Mac) 1334.65 MB 1334.66 MB +0.01 MB ⚪ 0.0%

site-editor

Metric trunk 9e64fd2 Diff Change
load 1608 ms 1644 ms +36 ms ⚪ 0.0%

site-startup

Metric trunk 9e64fd2 Diff Change
siteCreation 8999 ms 9012 ms +13 ms ⚪ 0.0%
siteStartup 4418 ms 4419 ms +1 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

@wojtekn

wojtekn commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

@borkweb I'm marking this as a draft as there are some conflicts to solve. Feel free to pass for review when those are fixed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

4 participants