Security#
BB Migrate exists to let one site read and overwrite another. This page says exactly how much a key holder can do under each switch, what the plugin does to keep that bounded, and what it does not protect against.
The model in short#
- One credential per site: the key in its connection string. Every request is signed with it.
- Three switches per site, all off by default: Allow pull, Accept push, Allow code files.
- Nothing is reachable without a valid signature. With a valid signature and all switches off, only the handshake answers.
- The sender drives. A receiver never calls out. Turning a site's switches off, deactivating the plugin or regenerating its key ends what others can do to it.
Request authentication#
Every request carries X-BB-Migrate-Signature: <ts>.<nonce>.<hmac>: an HMAC-SHA256 over the timestamp, a
per-request nonce, the route and the body, keyed with the receiver's key. The receiver:
- rejects a timestamp more than 300 seconds from its own clock,
- compares the HMAC in constant time,
- binds the signature to one route and one body,
- records each accepted signature as a marker file created exclusively, so a signature is good once. If the marker store is unusable the request is refused, not let through.
The full description is in Protocol.
What each switch gives a key holder#
| Switches on | A holder of the key can |
|---|---|
| none | Run the handshake: learn the site's URLs, server paths, WordPress, PHP and plugin versions, table prefix, table names and sizes, and the switch values |
| Allow pull | Read every table that has the site's prefix, including users and their password hashes; list and download everything in uploads (except what the uploads rule refuses); search posts and export any post as a package |
| Accept push | Replace the database; write and delete files in uploads; import posts; set BB Cache settings; purge; regenerate snippet files; write post meta; list plugins |
| Accept push + Allow code files | All of the above, plus write and delete files in theme and plugin folders, and activate and deactivate plugins |
| Allow pull + Allow code files | Also read theme and plugin folders |
What "Accept push" means#
Accept push is administrator-level access, by design. A pushed database brings its own users, options and
content. A pushed snippet post, or a .js file pushed into uploads/custom-css-js/, is script that runs for every
visitor. Whoever can push to a site can make themselves an administrator of it. Give out a connection string for a
site with Accept push on only to people and systems you would give an administrator account.
It is not code execution on the server. Since 0.2.2 nothing that is possible under Allow pull and Accept push alone writes PHP to disk or changes the list of active plugins:
- the uploads scope refuses executable extensions, server configuration files and dotfiles;
- a snippet's options are validated, so the snippet plugin cannot be made to write a
.phpfile; - pushed SQL is limited to creating and filling temp tables;
- the receiver's
active_pluginssurvives a push unless Allow code files is on; - packaged meta is unserialized without instantiating objects.
Allow code files is code execution, by design. It lets a key holder put PHP into theme and plugin folders and activate plugins. Turn it on for a deploy and off again afterwards.
A push can never change the receiver's own key or switches: bb_migrate_settings is restored after every import,
whatever the sender asks.
Hardening in 0.2.2#
Version 0.2.2 was a security review. Each item below closed a way in which "Accept push" (or a pull) reached further than intended.
| Area | Change |
|---|---|
| SQL from another site | A strict allow-list replaced the check on a statement's first keyword: only DROP TABLE IF EXISTS, CREATE TABLE … ( and INSERT INTO … VALUES on _bbmig_ temp tables and the dump's session header lines are accepted, checked with every quoted literal blanked. Versioned-comment tricks, INSERT … SELECT, CREATE … SELECT, ALTER … RENAME and SET on global variables are refused. The same list now applies to the dump a pull downloads |
| Snippet posts | A Custom CSS & JS snippet's options must describe a css, js or html snippet with a plain preprocessor ID, whether it arrives in a package, by a meta write or through the snippets action. Before, language=php wrote a PHP file under uploads with Accept push alone |
| Uploads rule | The uploads scope refuses an executable extension anywhere in a file name (shell.php.jpg), .htaccess, .user.ini, web.config, php.ini and all dotfiles |
| Path containment | Every read, write and delete checks that the resolved path, symlinked ancestors included, lies inside the resolved scope root |
| Object instantiation | Packaged meta is unserialized with allowed_classes => false. Objects round-trip as incomplete classes and no __wakeup or __destruct of an installed class runs. Attachment paths and image size file names in a package must be valid upload paths |
| The receiver's own settings | bb_migrate_settings (key, switches) is always restored after an import, and active_plugins is kept on a push unless Allow code files is on. Options named bb_migrate_… never leave a site in an export, backup or push |
| Backup names | The name of the pre-push backup file is no longer reported to the pushing site. No storage file name is ever sent to another site |
| Compatibility mu-plugin | It verifies the signature itself before switching anything off, and acts only on the plugin's own REST path. The earlier version acted on any URL that contained bb-migrate/v1, query strings included, so any request carrying the header ran with every other plugin disabled |
| Replay markers | One marker file per signature, created exclusively. Transients were wiped by the plugin's own cache flushes under a persistent object cache |
| "Local" detection | .dev is a public top-level domain and no longer counts as local, so such a site asks for its host name or --yes before its database is overwritten |
| Own folder | BB Migrate's folder is not a scope on either side; plugin:bb-migrate is "Unknown scope". New versions arrive by zip upload |
| Confirmations | files push --delete, site activate, site deactivate, pages push --on-exists=update and meta push ask for confirmation unless --yes |
| Handshake and pulls | Only the site's prefixed tables are listed or exported to a remote; other tables in a shared database stay invisible |
| Client | No redirects; https required except to local-looking hosts; private and loopback targets refused from a non-local site |
| Admin screen | Strings supplied by the remote (titles, errors, file names, report URLs) are escaped before they are rendered |
| Job records | A job record holds the connection string. Finished and failed jobs are deleted at once and stale ones after a day |
Rules on the receiving side#
Files. Only the fixed scope roots can be addressed. .., absolute paths, NUL bytes and symlinks are refused.
Chunked writes go to a .bbpart file and are renamed into place. Deletion obeys the same rules, removes files only,
and is requested only when the sender asked for a sync with delete. See Files.
Database. Incoming SQL runs into temp tables and is swapped in with one RENAME TABLE after everything arrived.
Errors returned to the sender do not include SQL fragments.
Site actions. BB Cache settings are merged key by key against that plugin's own defaults, with format checks on
the host, URL and Cloudflare values; no other option can be written. A stored Cloudflare token is never sent back.
Meta writes target one post found by slug and type and never a hidden _key, except the ACF reference that
accompanies its value. BB Migrate cannot be deactivated through its own site action.
Storage. Dumps and packages in uploads/bb-migrate/ have 32 random characters in their names; the folder has a
.htaccess that denies access, an index.php and an empty index.html; files are deleted after keep_files_hours.
Admin. Every admin action requires the manage_options capability and a nonce. Overwriting the database of a
site that is not local, a remote's database, or a remote's pages in place requires typing the host name.
Client-side rules#
The site that drives a transfer is also exposed: it sends a key, a database and files to a URL someone typed.
- No redirects. The client does not follow redirects. A redirect would carry the signed body, and with it the database, to another host.
- HTTPS except to local hosts. A connection string with
http://is accepted only when the host looks local: loopback, private IPv4 ranges, single-label names (Docker service names), and.local,.test,.localhost,.internal,.lan. Otherwise the database and files would travel in clear. - Unsafe URL rejection. Unless the sending site is itself local, requests to private and loopback addresses are
refused (WordPress's
reject_unsafe_urls), so a connection string cannot point a public site at its own internal network. - Certificate verification is on by default. Turn "Verify SSL certificates" off only between local containers.
- Pulled data is not trusted. A pulled dump goes through the SQL allow-list; pulled package meta is unserialized without objects; strings from the remote are escaped in the admin screen.
Known limits#
- The key is stored in the database, in the
bb_migrate_settingsoption, like any plugin secret. Anyone who can readwp_optionscan read it. Saved connections store other sites' keys there too, in plain text. - A pull sees every prefixed table, user records and password hashes included. There is no per-table permission on the receiver; the sender chooses the tables.
- The handshake tells any valid key holder the server paths, versions and table names, with all switches off.
- Transport privacy is TLS. The signature authenticates a request; it does not encrypt it. Keep certificate verification on.
- Static file servers ignore
.htaccess. Where nginx (or a CDN) serves uploads directly, the storage folder's deny rule does not apply and the random file name is the only protection for a dump. Keep the retention short and add a server rule. See Hosting notes. - A SQL file imported by hand is not allow-listed.
wp bb-migrate importand the Import tab execute the file an administrator chose. - Accept push is administrator-level access. The hardening above keeps it from being more than that; it does not make it less.
- Find and replace and file comparison are not integrity checks. Files are compared by size and time, not by hash.
Operating advice#
- On production, keep the plugin inactive, or all three switches off, between deploys. The signed endpoint can replace a database and, with Allow code files, write code. It should not be standing open.
- Turn on only the switch a task needs, for as long as the task takes. Allow code files in particular.
- Regenerate the key after a migration, and whenever a connection string may have been exposed
(
wp bb-migrate key --regenerate). - Prefer the zip path for production content: export a page package on the source, upload it on production's Pages tab. No switch needs to be on.
- Never commit a connection string, paste it into a ticket or log it. Keep it in a file outside version control.
- Keep both sites on the same plugin version; update both before a transfer.
- Before using file sync on a server for the first time, check that PHP can write to the target folder, and that the ownership it leaves behind suits your other deployment tools. See Hosting notes.
To report a vulnerability, contact the Brightbase team privately. Do not open a public issue.