Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions actions/cf-preview/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ const RESOURCE_KINDS = {
};
// Kinds that can reach Production state and are never allowed without an explicit allowlist entry.
const HIGH_RISK = ['services', 'hyperdrive', 'secrets_store_secrets'];
// Kinds a caller may share with Production through an exact `kind:BINDING=identity` preview_binding_allow entry.
// Wider than Shared Dev's list on purpose: a Secrets Store secret can be approved for Preview only (never D1).
const PREVIEW_SHAREABLE_KINDS = [...DEV_SHAREABLE_KINDS, 'secrets_store_secrets'];

function bindingKey(kind, b) {
return b.binding ?? b.name ?? JSON.stringify(b);
Expand All @@ -201,7 +204,9 @@ function queueProducers(cfg) {
* `allow` is a list of "kind:binding" strings the caller has explicitly approved
* (e.g. "services:ID_SERVICE"), `denyResources` extra Production identities to reject. An entry
* "kind:BINDING=identity" (e.g. "r2_buckets:CACHE=prod-bucket") additionally lets that exact binding share that
* exact Production resource; only storage kinds in DEV_SHAREABLE_KINDS qualify, never D1.
* exact Production resource; only storage kinds in DEV_SHAREABLE_KINDS plus secrets_store_secrets (identity
* `store_id/secret_name`) qualify, never D1. For a high-risk kind that exact entry also satisfies the explicit-allow
* requirement of the binding it names; a bare `kind:BINDING` entry never waives the Production collision.
*/
export function auditPreviewConfig(cfg, { allow = [], denyResources = [] } = {}) {
const errors = [];
Expand Down Expand Up @@ -231,15 +236,17 @@ export function auditPreviewConfig(cfg, { allow = [], denyResources = [] } = {})
for (const b of prev) {
const key = `${kind}:${bindingKey(kind, b)}`;
const mine = ids(b).filter(Boolean);
let sharedExact = mine.length > 0;
if (!mine.length) errors.push(`${key}: Preview binding needs an explicit resource id (Native Previews do not auto-provision; Cloudflare rejects it with 10021)`);
for (const id of mine) {
// An exact "kind:BINDING=identity" entry for a shareable storage kind approves sharing that one
// resource under that one binding; it waives the Production-collision and denylist checks only.
if (DEV_SHAREABLE_KINDS.includes(kind) && allowed.has(`${key}=${id}`)) continue;
if (PREVIEW_SHAREABLE_KINDS.includes(kind) && allowed.has(`${key}=${id}`)) continue;
sharedExact = false;
if (prodIds.has(id)) errors.push(`${key}: Preview binds the same resource as Production (${id})`);
if (deny.has(id)) errors.push(`${key}: Preview binds denylisted Production resource (${id})`);
}
if (HIGH_RISK.includes(kind) && !allowed.has(key)) {
if (HIGH_RISK.includes(kind) && !allowed.has(key) && !(sharedExact && PREVIEW_SHAREABLE_KINDS.includes(kind))) {
errors.push(`${key}: high-risk binding needs explicit allow entry "${key}"`);
}
}
Expand Down Expand Up @@ -399,7 +406,7 @@ export function auditProdConfig(effective, source, { allow = [] } = {}) {
for (const b of source.previews?.[kind] ?? []) {
const key = `${kind}:${bindingKey(kind, b)}`;
ident(b).filter(Boolean).forEach((id) => {
if (DEV_SHAREABLE_KINDS.includes(kind) && allowed.has(`${key}=${id}`)) return;
if (PREVIEW_SHAREABLE_KINDS.includes(kind) && allowed.has(`${key}=${id}`)) return;
previewIds.add(id);
});
}
Expand Down
1 change: 1 addition & 0 deletions docs/cf-worker-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Production Version, bound to Production resources), set `preview_worker_name` to
Adapters can add bindings at build time (the Astro Cloudflare adapter adds a `SESSION` KV), so preflight audits both the source config and the build-generated (redirected) config.
* `services`, `hyperdrive`, `secrets_store_secrets` in `previews` need an explicit `preview_binding_allow` entry (e.g. `services:ID`): Preview service bindings reach the target's **Production** deployment.
* To deliberately share one Production storage resource (R2, KV, Vectorize, Analytics Engine) with Previews, add an exact `preview_binding_allow` entry `kind:BINDING=identity` (e.g. `r2_buckets:CACHE=my-bucket`), the same grammar as `dev_binding_allow`. It waives the Production-collision and `preview_deny_resources` checks for that binding and resource only; a different resource or binding still fails, and a Production D1 can never be shared. Isolation inside the shared resource (e.g. a key prefix) is the caller's responsibility.
* A Production **Secrets Store secret** can be shared the same way with the exact entry `secrets_store_secrets:BINDING=<store_id>/<secret_name>` (e.g. `secrets_store_secrets:FATSECRET_CLIENT_ID=<store_id>/FATSECRET_CLIENT_ID`). It waives the Production-collision and `preview_deny_resources` checks and the high-risk allow requirement for that one binding+identity only. A bare `secrets_store_secrets:BINDING` entry only satisfies the high-risk check (a Preview-only secret) and never waives the collision; a different store/secret/binding still fails. Shared Dev (`dev_binding_allow`) is unchanged and does not support secrets.
* No `routes`, `triggers` or queue consumers in `previews`; `assets` and `compatibility_*` stay top-level.
* Preview secrets are **not** inherited from Production: `wrangler preview base-config secret put <NAME>`.

Expand Down
35 changes: 35 additions & 0 deletions scripts/cf-preview/lib.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,41 @@ test('preflight: exact kind:BINDING=identity allow shares one Production R2 buck
assert.equal(auditPreviewConfig(g, { allow: ['r2_buckets:ASSETS_R2=other-bucket'] }).errors.length, 1);
assert.equal(auditPreviewConfig(g, { allow: ['r2_buckets:OTHER=prod-bucket'] }).errors.length, 1);
});
const withSecret = (prevName = 'FS_ID', prevStore = 'store-1', prevSecret = 'FS_ID') => {
const g = good();
g.secrets_store_secrets = [{ binding: 'FS_ID', store_id: 'store-1', secret_name: 'FS_ID' }];
g.previews.secrets_store_secrets = [{ binding: prevName, store_id: prevStore, secret_name: prevSecret }];
return g;
};
test('preflight: Secrets Store secret shared with Production needs the exact store_id/secret_name entry', () => {
const entry = 'secrets_store_secrets:FS_ID=store-1/FS_ID';
const g = withSecret();
assert.ok(auditPreviewConfig(g).errors.some((e) => /same resource as Production/.test(e)));
// a bare kind:BINDING entry satisfies the high-risk check but never waives the Production collision
const bare = auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID'] }).errors;
assert.equal(bare.length, 1);
assert.match(bare[0], /same resource as Production/);
// the exact entry waives the collision and satisfies the high-risk check for that binding
assert.deepEqual(auditPreviewConfig(g, { allow: [entry] }).errors, []);
assert.deepEqual(auditPreviewConfig(g, { allow: [entry], denyResources: ['store-1/FS_ID'] }).errors, []);
// another secret / store / binding is not approved
assert.ok(auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID=store-1/OTHER'] }).errors.length > 0);
assert.ok(auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID=store-2/FS_ID'] }).errors.length > 0);
assert.ok(auditPreviewConfig(g, { allow: ['secrets_store_secrets:OTHER=store-1/FS_ID'] }).errors.length > 0);
});
test('preflight: a Preview-only Secrets Store secret still needs the explicit high-risk allow', () => {
const g = withSecret('FS_ID', 'store-1', 'FS_ID_PREVIEW');
assert.match(auditPreviewConfig(g).errors[0], /high-risk binding needs explicit allow entry/);
assert.deepEqual(auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID'] }).errors, []);
// an exact entry for a different identity does not approve it
assert.ok(auditPreviewConfig(g, { allow: ['secrets_store_secrets:FS_ID=store-1/FS_ID'] }).errors.length > 0);
});
test('production audit: an approved shared Secrets Store secret is not a Preview-only resource', () => {
const g = withSecret();
const src = g;
assert.ok(auditProdConfig(g, src, {}).errors.length > 0);
assert.deepEqual(auditProdConfig(g, src, { allow: ['secrets_store_secrets:FS_ID=store-1/FS_ID'] }).errors, []);
});
test('preflight: a Production D1 can never be approved for sharing', () => {
const g = good();
g.previews.d1_databases[0].database_id = 'prod-id';
Expand Down
Loading