Security / Security Controls

Credentials and Persistence

Drupal's database connection reaches cfwSqlExec, which applies no table filter, so any code that can run SQL can read and write every table in the object. These changes move authority out of SQL.

Sealed storage. ctx.storage.kv is a separate table in the object's SQLite file that the SQL bridge cannot read or write. Measured on a local workerd 1.20261012.1 (owner-recovery.spec.ts): SELECT, INSERT, UPDATE, DELETE and DROP on _cf_KV all answer SQLITE_AUTH, from the SQL bridge and from PHP's cfw_sql(); ATTACH and DETACH are denied; a view, a subquery, a table copy and a trigger over _cf_KV read nothing; and a table renamed onto the name _cf_KV does not become the key-value table. sqlite_master and pragma_table_list still reveal that the table exists. The rename case is tested because Check Point reported a rename-based authorizer bypass in 2026 that Cloudflare says it fixed in production, with no date given (Check Point, extract). The local build refuses the rename, and the hosted fix is not independently dated, so the sealed-storage claim on the platform is contained and not removed.

The owner token. Only its SHA-256 is stored, in sealed storage, and the check compares hashes in constant time. A claim returns the token once. Recovery rotates the token, returns rotated: true and the new value, and drangler recover-token stores the new value and lists every place that still holds the old one. A site upgraded from a version that kept a plaintext owner_token row adopts it at wake and at every alarm: the row is hashed into sealed storage and deleted, and a sealed value always wins over a row. The git tokens, webhook secrets and Cloudflare OAuth grant follow the same path. A restore point from before the upgrade brings the old row back and the next wake adopts it again, so rotate through recovery after restoring to one. Verdict: contained (Measured, site-secrets.spec.ts, owner-recovery.spec.ts, git-remotes.spec.ts, falsified by mirroring the hash into cfw_meta and by removing rotation). Residual: a Cloudflare account holder can read the object's storage. The keys that moved are SEALED_META_KEYS in src/ops/site-secrets.ts, and a legacy row is adopted on wake and at each alarm. The deployed check (a SELECT on cfw_meta of a live site, and a point-in-time restore) has not run.

Export and import. A default export carries none of these values. /export?secrets=1 carries the sealed values as ordinary cfw_meta rows (owner_token_hash, cf_oauth_token, git_token_*, git_hooksecret_*), and an import adopts them on the next wake, so a demo or a migration keeps working without a manual step (serve-restore.spec.ts, 7 cases). A dump from a pre-sealed worker still carries the owner token in the clear.

Module files. mountInstalledModules() mounts every row of cfw_module_file into the interpreter's file system on every boot, and PHP's SQL connection can write that table, so a planted row used to survive a restart, a Drupal patch and a redeploy. On a claimed site each row is now checked, when the file is first opened, against a SHA-256 record in sealed storage. One function, changeModuleFiles in src/do/module-files.ts, writes records and rows together for installs, git pulls, /modify, rollbacks, removals and branch switches. A row with no record or other bytes mounts as an empty file, is counted under moduleIntegrity in /__serve-stats, and is logged once per path. Measured on a local workerd: hashing 1,040 files and 6.1 MB costs 4 to 10 ms with node:crypto (550 ms with the pure-JavaScript SHA-256, which is why it is not used), and first open of every file read 42 to 46 ms claimed against 41 to 49 unclaimed (modify-upload.spec.ts, falsified). Verdict: contained. It stays contained for these reasons:

Heap images. A boot restores a stored interpreter heap from cfw_heap_snapshot and cfw_heap_chunk instead of booting PHP. The restore is gated by HEAP_SNAPSHOT, which is on unless it is 0 or a deployment environment is set, and not by HEAP_IMAGE, which gates only the producer. The digests sat in the same tables as the bytes, so code with SQL write could plant a snapshot with matching digests and have its memory restored on the next boot. That was open on a default site: with the new check bypassed, a planted row restored on the shipping configuration (Measured, heap-restore-autoload.spec.ts). A SHA-256 root over the stored rows and over the metadata that steers a restore (page index, fd table, handle table, generation, sizes) is now sealed and verified before any byte, handle or descriptor reaches the instance, and per chunk on a sliced restore. A planted, altered or unsealed image is refused and deleted, the site boots from the pack, and heapIntegrity in /__serve-stats counts it. Verified restore cost: 2 to 3 ms against 71 to 72 ms of copying, local workerd. Verdict: contained. SQL write can still delete images, which costs a boot and nothing more, and the survival of a sealed record across a point-in-time restore is Inferred.

Authority keys. git_remotes (a remote pointed at another repository installs that tree on the next pull), recover_spent, first_run_at and first_run_keys (a missing claim stamp reopens the claim window), and the four OIDC keys (issuer, client id, login in flight, claims ticket) are in sealed storage, and cfw_meta holds none of them. metaGet and metaSet route them, so a caller cannot write one back to SQL. A row from an earlier build is copied over on the first read or at wake, read back, then deleted; a run cut off between the copy and the delete repeats with the sealed value winning. A dump with secrets=1 carries the six that are worth restoring and a plain dump carries none (serve-restore.spec.ts). A point-in-time restore has not been run against it: the local runtime has no change log, and sealed storage shares the database file the restore rewinds, which is Inferred.

The owner audit log. Owner calls that change something append to a hash chain held in sealed storage: sequence, time, route label, method, status, a token fingerprint, and the previous hash, in a ring of 1,000. Each append also writes {seq, hash} to Workers observability, so a rewrite of the ring is checkable against a copy outside the object. GET /audit and drangler audit --verify recompute the chain. It is evidence against a site-level attacker and none against the account holder, who can edit the object's storage. Two gaps: a point-in-time restore rewinds the log and nothing in the object can append after it, and the first claim is not an entry. A refused token adds no row.