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
33 changes: 24 additions & 9 deletions ghost/core/core/shared/config/SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,7 @@ failure for feature config, which is the wrong behaviour for most of it.
the schema changed a value rather than only checking it.

Adding a key to the schema enforces its type: a caller that treated a string as
a number, or assumed a key is always present, stops compiling. That is the point
— the compiler is what keeps config honest, because the freeze on its own cannot.
Ghost's `.js` files are sloppy-mode CommonJS, where writing to a frozen object is
dropped rather than thrown, and `node --use-strict` does not change that: it makes
only the entry point strict, while a `require()`d CommonJS module keeps its own
strictness. Enforcing the freeze at runtime would take `'use strict'` directives
across the `.js` files, or a Proxy whose `set` trap throws, gated to the test
environment.
a number, or assumed a key is always present, stops compiling.

## Rules

Expand Down Expand Up @@ -128,7 +121,29 @@ change.

## Don't mutate what `get()` returns

It is frozen, and it is shared: every reader of a key gets the same object. Build
In `development` and under test a write throws, naming the key path:

```
TypeError: Ghost config is read-only: attempted write to `paths:contentPath`.
```

In production it does not. The config is deep-frozen there, and Ghost's `.js`
files and its CommonJS dependencies are sloppy-mode, where a write to a frozen
object is dropped without an error. `node --use-strict` does not change that: it
makes only the entry point strict, and a `require()`d CommonJS module keeps its
own strictness.

So the loud failure is a development and CI affordance - [`guard.ts`](guard.ts)
swaps the freeze for a proxy whose traps throw, which fires whatever mode the
caller is in. Production keeps the frozen object because it is the cheaper read;
the two cannot be combined, since a proxy over a deep-frozen target may not
return a wrapped child. `GHOST_CONFIG_GUARD` overrides in either direction.

Guarded reads cost more - a scalar read goes from 4.5ns to 21ns and a spread of a
config object from 120ns to 2.4µs - which is why it is not on in production. It
makes no measurable difference to the test suite.

Config is shared as well as read-only: every reader of a key gets the same object. Build
a derived object instead. Three places got this wrong, each writing through into
config for every later reader:
[`configure-knex.ts`](../../server/data/db/configure-knex.ts) assembling knex's
Expand Down
124 changes: 124 additions & 0 deletions ghost/core/core/shared/config/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* A read-only guard for config, for the environments this repo runs itself.
*
* Deep-freezing config makes it immutable but not loud: Ghost's `.js` files and
* its CommonJS dependencies are sloppy-mode, where a write to a frozen object is
* dropped without an error. `node --use-strict` does not help - it makes only the
* entry point strict, and a `require()`d CommonJS module keeps its own
* strictness. A proxy trap throws whatever the caller's mode, so development and
* CI fail on a write that production would merely lose.
*
* Production keeps the frozen object: it is the cheaper read, and the guard's
* value is in finding writes before they ship. The two are mutually exclusive -
* a proxy over a deep-frozen target cannot return a wrapped child, because the
* invariant for non-writable, non-configurable properties forbids handing back
* anything other than the target's own value - so a guarded tree is left
* unfrozen and relies on the traps instead.
*/

const wrappers = new WeakMap<object, unknown>();

/**
* Whether writes to config should throw rather than be dropped.
*
* Matches the environments ./validated.ts validates strictly, for the same
* reason: a mistake should fail a developer's boot or CI, not a live site.
* `GHOST_CONFIG_GUARD` overrides in either direction.
*/
export function shouldGuard(env: string): boolean {
const override = process.env.GHOST_CONFIG_GUARD;

if (override !== undefined) {
return override === 'true';
}

return env === 'development' || env.startsWith('test');
}

function isWrappable(value: unknown): value is object {
if (value === null || typeof value !== 'object') {
return false;
}

// leave class instances and anything else exotic alone - config is plain data,
// and wrapping something with its own internal slots breaks it
const proto = Object.getPrototypeOf(value);

return Array.isArray(value) || proto === Object.prototype || proto === null;
}

/**
* Wrap a config tree so that any write throws, naming the key path.
*
* Children are wrapped lazily from the get trap, so only the parts actually read
* pay for it, and memoised per underlying object, which keeps `===` stable
* between reads of the same subtree and terminates on cycles.
*/
export function guardReadOnly<T>(tree: T, path = ''): T {
if (!isWrappable(tree)) {
return tree;
}

const existing = wrappers.get(tree);

if (existing !== undefined) {
return existing as T;
}

const refuse = (prop: string | symbol): never => {
// A native TypeError on purpose: it is what a strict-mode write to a frozen
// object throws, so guarded and frozen config fail the same way. Config also
// must not depend on @tryghost/error.
// eslint-disable-next-line ghost/ghost-custom/ghost-error-usage
throw new TypeError(
`Ghost config is read-only: attempted write to \`${path ? `${path}:` : ''}${String(prop)}\`. ` +
'Build a derived object instead - see core/shared/config/SCHEMA.md.',
);
};

const proxy = new Proxy(tree, {
get(target, prop) {
const value = Reflect.get(target, prop);

// symbols carry iterators and inspection hooks, and functions are array
// and object methods - wrapping either breaks them
if (typeof prop === 'symbol' || typeof value === 'function') {
return value;
}

return guardReadOnly(value, path ? `${path}:${String(prop)}` : String(prop));
},
// without this, Object.getOwnPropertyDescriptor(guarded, k).value hands back
// the raw child and a write through it misses every trap. Safe to wrap:
// a guarded tree is left unfrozen, so its properties stay configurable and
// returning a different value does not break a proxy invariant.
getOwnPropertyDescriptor(target, prop) {
const descriptor = Reflect.getOwnPropertyDescriptor(target, prop);

if (!descriptor || !('value' in descriptor) || typeof prop === 'symbol') {
return descriptor;
}

return {
...descriptor,
value: guardReadOnly(descriptor.value, path ? `${path}:${String(prop)}` : String(prop)),
};
},
set(_target, prop) {
return refuse(prop);
},
defineProperty(_target, prop) {
return refuse(prop);
},
deleteProperty(_target, prop) {
return refuse(prop);
},
setPrototypeOf() {
return refuse('[[Prototype]]');
},
});

wrappers.set(tree, proxy);

return proxy as T;
}
47 changes: 32 additions & 15 deletions ghost/core/core/shared/config/validated.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import type { ReadonlyDeep } from 'type-fest';
import _ from 'lodash';
import { z } from 'zod';
import { bindAll as bindUrlHelpers, type BoundHelpers } from '@tryghost/config-url-helpers';
import { bindAll as bindHelpers, type ConfigHelpers } from './helpers';
import { guardReadOnly, shouldGuard } from './guard';
import { configSchema, type ConfigAt, type ConfigPath, type ValidatedConfig } from './schema';

/**
Expand Down Expand Up @@ -49,17 +51,28 @@ function isStrict(env: string): boolean {
}

/**
* Validate a config tree and deep-freeze it.
* Make a validated tree read-only: frozen in production, proxied in the
* environments this repo runs itself so a write throws rather than being
* dropped. See ./guard.ts.
*/
function readOnly(tree: Record<string, unknown>): ValidatedConfig {
return (
shouldGuard(String(tree.env)) ? guardReadOnly(tree) : deepFreeze(tree)
) as ValidatedConfig;
}

/**
* Validate a config tree and make it read-only.
*
* Freezes in place, so the caller must hand over a tree nothing else holds -
* zod passes keys the schema does not name straight through by reference, so a
* shared tree would be frozen out from under its other owner.
* Takes ownership of the tree: it is frozen in place, and zod passes keys the
* schema does not name straight through by reference, so a shared tree would be
* frozen out from under its other owner.
*/
export function validateConfig(tree: Record<string, unknown>): ValidatedConfig {
const result = configSchema.safeParse(tree);

if (result.success) {
return deepFreeze(result.data);
return readOnly(result.data as Record<string, unknown>);
}

const report = z.prettifyError(result.error);
Expand All @@ -75,7 +88,7 @@ export function validateConfig(tree: Record<string, unknown>): ValidatedConfig {
`Ghost config failed validation (not enforced in the ${tree.env} environment):\n${report}`,
);

return deepFreeze(tree) as ValidatedConfig;
return readOnly(tree);
}

/**
Expand Down Expand Up @@ -134,12 +147,14 @@ function lookup(root: unknown, key: string): unknown {
}

/**
* Write a key path into a tree, copying each level on the way down if it is
* frozen.
* Write a key path into a tree, copying each level on the way down.
*
* An override's value is often something a test read back out of config, which
* is frozen, so a later override targeting a path inside it would otherwise be
* writing into a frozen object.
* The copy is unconditional, because an override's value is written into every
* later tree by reference and may be an object a caller still holds - usually
* one a test read back out of config. Writing a deeper path in place would
* reach back through both. Copying only when a level is frozen looks equivalent
* but is not: under the guard nothing is frozen, so the same nested override
* would mutate what an earlier `get()` returned.
*/
function writePath(tree: Record<string, unknown>, key: string, value: unknown): void {
const segments = key.split(':');
Expand All @@ -151,7 +166,7 @@ function writePath(tree: Record<string, unknown>, key: string, value: unknown):

if (child === null || typeof child !== 'object') {
node[segment] = {};
} else if (Object.isFrozen(child)) {
} else {
node[segment] = Array.isArray(child) ? [...child] : { ...child };
}

Expand Down Expand Up @@ -203,12 +218,14 @@ export function createConfig(sources: Record<string, unknown>): GhostConfig {

set(key: string, value: unknown): void {
// Cloned first, before anything is recorded: cloning can throw - on a
// value it cannot handle - and doing it here means that throw cannot
// leave `overrides` half-updated.
// value with a throwing getter, say - and doing it here means that throw
// cannot leave `overrides` half-updated. cloneDeep rather than
// structuredClone, because under the guard this value may be a proxy and
// structuredClone rejects those.
//
// The clone itself is needed because rebuild() makes whatever ends up in
// the tree read-only, and this value is the caller's own object.
const cloned = structuredClone(value);
const cloned = _.cloneDeep(value);
const previous = new Map(overrides);

// deleted first so the key moves to the end: overrides replay in
Expand Down
Loading
Loading