Repository navigation
Behavior changes to check before upgrading (details in each entry below):
- Composition
prunealso removes element children a payload omits under a named relation. - A link edited with
"publish": "none"now waits for its owner to be published. GET records:limit=0and non-numeric/negativelimit/offsetare400; an empty filter value (Title=) now filters.PATCH records/$ClassRef/$IDis new and open on live by default; close it withRecordUpdateHandler.disabled_environments: [live].- Stricter authorization on compositions, assets, publish cascades, convert (new
convertverb) and relation far sides; HTML fields are sanitised on write. - Regenerated exposure files drop framework-maintained fields/relations; fingerprint output shifts once for affected sites.
[2.9.0] - 2026-10-18
The 2026-10 security/correctness audit (epic #133: #221/#222, #225-#246, #231, #232), plus
#207 and #277, all landed on branch 1 first (1.15.0). Behavior changes are called out in their
entries; the main ones are listed in the release notes.
Added
- (#232)
PATCH records/$ClassRef/$IDwith{"fields": {...}, "publish": "none|single"}:
a single-record field update that needsCONTENT_API_ACCESSonly and is not behind the
population gate, so it works on a live site. Same checks as a batchupdateop (class verb on
the record's own class,canEdit(), write policy and allowlist,actionforpublish: single); no creates or relations. It is open on live by default:
RecordUpdateHandler.disabled_environmentscloses it per environment (403 ENV_FORBIDDEN,
details[0].confignames the key), andGET schema/sitereportsrecordUpdateEnabled. See
docs/en/04_security-model.md#live-writes-patch-recordsclassrefid for what it can reach. New
content_records_updatetool inschema/endpoints.json.
Landed on branch1first (unreleased), merged up here. - (#231) Opt-in grant roots for
ContentApiGrantExtension:grant_rootsnames ancestors
(e.g.SiteTree,BaseElement) whose undeclared subclasses are granted the verbs they inherit;
grant_exclude(shipping withErrorPage,RedirectorPage,VirtualPage,UserDefinedForm)
takes classes back out. A class's own declaration (an explicit deny included) still wins, the
inherited value never comes from an extension or discovery, and with no roots nothing
changes. NewClassRegistry::grantVerbs();CheckGrantExtensionReachabilityreports the
roots and exclusions and checks inherited grants. See docs/en/04_security-model.md#grant-roots-opt-in.
Landed on branch1first (unreleased), merged up here. - (#207) Opt-in structured server-side request log (
Dynamic\ContentApi\Logging\RequestLogger):
oneinfoentry percontent-api/v1request (endpoint, method,ClassRef/action, HTTP status,
error code, duration, response byte size, authenticated member ID). Off everywhere by default;
enabled_environmentsconfig (widen-only via project YAML, per #199) plus a force-ON-only
SS_CONTENT_API_REQUEST_LOGoverride. A partially-failedPOST batchcarries itsopFailures
count even though the wire response is a plain 200 — a status-only log would otherwise record
that case as a clean success, the exact "200 but silently wrong" defect class this audit's other
fixes address at the code level. Colymba's generic/apisurface and a route matching no
$url_handlerspattern are both outside this hook's coverage — see
docs/en/14_architecture.md#request-lifecycle. Also the missing piece for
silverstripe-content-api-mcp#25's open success criterion (a server-side way to verify a real
restructure session ran through MCP tools).
Landed on branch1first (unreleased), merged up here.
Security
-
(#227, #229, #235) Composition and asset write paths now check the verb for what they do to
each record, at both the class and record level (newPermissionPolicy::authorize()). See
docs/en/04_security-model.md#composition-and-asset-paths for the full table.- Composition
pruneneedsdeleteon every archived element pluscanDelete(). All
candidates are checked before any is archived. - A match-only composition (no
page.fields) needs the page classupdateverb.
createIfMissingneeds the page classcreateverb. AparentIdmust exist (404 NOT_FOUNDotherwise) and reachescanCreate()'s context asParent. POST assetsand compositionassets[]share one authorization
(AssetService::authorizeIngest()). A new file needscreatepluscanCreate()with its
folder asParent(andFolder::canCreate()for a missing folder).overwriteneeds
updatepluscanEdit()on the existing file, found on the draft stage.skipneeds
read. Publishing needsaction.
Behavior changes:
- An asset upload that does not send
publish, on a class withoutaction, is now written
draft-only with anACTION_VERB_MISSINGwarning indata.warnings; it used to publish. An
explicit"publish": truewithoutactionis403 FORBIDDEN_CLASS. - A composition
assets[]entry without its ownpublishnow follows the composition: it is
draft-only underpublish: "none"(it used to publish). - Composition asset ingest,
overwriteandprunenow need the verbs above, where they
previously needed none of them.
Landed on branch1first (unreleased), merged up here.
- Composition
-
(#239) An asset
publishvalue is parsed strictly, in JSON and multipart alike: the string
"false"no longer reads as true, and anything other than a boolean,1/0or
"true"/"false"/"1"/"0"is400 PAYLOAD_INVALID.
Landed on branch1first (unreleased), merged up here. -
(#226) Publish mode
recursive(POST records/$Class/$ID/publishand a payloadpublish
key) now checks every$owns-reachable recordpublishRecursive()will publish: each needs
the classactionverb and the record check, the same walk and gateownsmode applies. It
previously published owned drafts of classes the caller had no verb on. All are checked
before anything is published. An owned record whose draft and live already match is skipped,
sincepublishRecursive()leaves it untouched. Behavior change: a recursive publish
reaching a changed owned record of a class withoutaction(a draft-only image on a
read/create-onlyFile, say) is now403. The walk reads the draft stage, so an owned
record that exists only on Live (deleted from draft) and thatpublishRecursive()removes
from Live is not checked.
Landed on branch1first (unreleased), merged up here. -
(#235) Publish and unpublish now honour a model's
canPublish()/canUnpublish()(new
PermissionPolicy::checkStageAccess()), falling back tocanEdit()without them. This applies
to:- the root of every publish mode;
- every record a
subtree/owns/recursive/relation cascade reaches; - every unpublish;
- any delete that takes a record off Live: batch
deleteinunpublishmode, and
archive/hardon a published record (the CMS'scanArchive()askscanUnpublish()
too); - asset publishes of an existing file.
Behavior change: a class or extension that allows edit but denies publish or unpublish now
gets403 FORBIDDEN_RECORDfor those operations.ContentApiGrantExtensiondefines no
canPublish(), so service accounts it covers publish on itscanEdit()grant. The exception
is a model that overridescanPublish()directly withoutextendedCan(), where the model's
own rule now applies.
Landed on branch1first (unreleased), merged up here. -
(#228) Changing a page's class (
POST pages/$ID/convertand a composition's
page.convertTo) needs a new bareconvertverb inapi_accesson both the page's current
class and the target class, in addition toupdate(andactionwhen publishing). It was
previously allowed between any two classes grantingupdate.api_access: trueincludes
convert; a CSV list must name it ('GET,POST,PUT,action,convert'). Behavior change:
projects that convert pages must addconvertto the classes involved.
Landed on branch1first (unreleased), merged up here. -
(#235)
apply-templateneeds the page classupdateverb (not justcanEdit()),
canView()on the template, and thecreateverb pluscanCreate()on every class the apply
will create: each template element's class and every child classduplicate()cascades to.
All are checked before anything is written.
Landed on branch1first (unreleased), merged up here. -
(#235) Relation writes authorize the far side, before any list changes: a has_many item,
and every existing child a has_manysetclears, needs theupdateverb pluscanEdit()
(its foreign key is written); a many_many item needsreadpluscanView(). They previously
checked only the owning record. Behavior change: projects writing has_many relations need
updateon the child class.
Landed on branch1first (unreleased), merged up here. -
(#225) HTML-typed field writes (
DBHTMLText/DBHTMLVarchar) are sanitised with
HTMLEditorSanitiserand thecmseditor config's rules, on the module's pipeline and on
colymba's/api, so an API write stores what a CMS save would. Any token withupdateon a
class with an HTML field could previously put script into a page that renders it raw.
Behavior change: markup outside the editor rules is stripped, exactly as a CMS save strips
it. Beyond<script>andon*attributes, that includes, under the stockcmsrules: empty
<i>/<span>/<div>(icon and clearfix markup), HTML5 elements such as<section>,
<figure>,<picture>,<svg>,<video>and<button>(unwrapped), and attributes such as
data-*,srcsetandloading. A module write that lost markup carries a new
VALUE_SANITISEDwarning. New configWriteApplicator.sanitise_html(defaulttrue) turns
sanitising off, as does the CMS's ownHTMLEditorField.sanitise_server_side: false;
html_editor_config(defaultcms) picks the editor config whose rules apply, and one with no
usable rules refuses the write (500 SERVER_ERROR) rather than storing raw or stripped HTML.
Landed on branch1first (unreleased), merged up here. -
(#230) colymba's
/apiwrites drop any payload key that isn't a schema name for the class
(a DB column, a has_one or itsID/Class/Relationcolumn, a many relation,
ManyManyExtraFields). colymba assigns every key with$model->{$key} = $value, which runs a
matchingset{Key}()setter even for a non-field key (File::setFilename()re-parenting a
file,Versioned::setMigratingVersion()). Dropped keys are logged at notice level.
Landed on branch1first (unreleased), merged up here. -
(#235) colymba's own relation write (
removeAll()thenadd()per id) now authorizes
the far side like the module pipeline:update+canEdit()on every has_many item and every
existing child it clears,read+canView()on every many_many item. A refusal is403.
Landed on branch1first (unreleased), merged up here. -
(#237) colymba's unauthenticated
api/auth/logoutandapi/auth/lostPasswordare off by
default;loginstays on.ColymbaAuthHardening.auth_actions(a string-keyed map project
YAML can flip) is applied to colymba'sallowed_actionsby a new module_config.php. The
?token=query-var fallback on/apiis closed by renamingTokenAuthenticator.tokenQueryVar.
Behavior change:logout/lostPasswordcallers get403, and/apiclients passing
?token=must send theX-Silverstripe-Apitokenheader (or settokenQueryVar: token).
Landed on branch1first (unreleased), merged up here. -
(#236) Read-side exposure closed on
/content-api/v1:- A subclass that explicitly denies or narrows its ancestor's verbs stays denied when its row
is reached through the ancestor's ref. Reads, list rows (andmeta.total), stage actions
(including the forced-unpublishdeletecheck), parity and upsert-to-existing all check
the record's own class. Asset endpoints resolve the nearest class that declares access,
so an explicitly deniedImageis no longer governed byFile's grant. - List filters and sorts accept only columns a response can show (
api_fields, emitted
has_one FKs,ID, the external-id column, minushidden_fields). A withheld column used to
leak its value through which rows matched. GET schema/$ClassRefrefuses a mapped class with no verbs (403 FORBIDDEN_CLASS) instead
of serving its full field inventory.- Every response carries
Cache-Control: no-cache, no-store, must-revalidate, private.
Behavior change: ancestor-ref reads and actions on explicitly denied subclass rows return
403and list totals drop; filtering or sorting on a never-emitted column returns422
(ClassName,IDand the external-id column stay filterable). A project that set
Image: api_access: falsemeaning "no opinion, use File's" must remove the key instead: an
explicitfalseon an asset class is now a deny.
Landed on branch1first (unreleased), merged up here. - A subclass that explicitly denies or narrows its ancestor's verbs stays denied when its row
Changed
- (#238) Partial-failure atomicity:
- A batch op's failure is always that op's own result. An unsettable value (unparseable
Date/Datetime, an array on a scalar column) is422 INVALID_VALUE, and any other exception is
a per-opSERVER_ERROR. Either used to escape as a500that dropped every result and left
earlier non-atomic ops committed. - Archive, unpublish and non-versioned delete (batch
deleteop and thearchiveaction) run
in a transaction, so a hook that throws mid-archive no longer leaves a record unpublished but
still on draft. subtreeandownspublish cascades run in one transaction for every caller, so a hook
failing on target N no longer leaves targets 1..N-1 live.- Composition assets are ingested draft-only and published after the composition's
transaction commits. A later failure no longer deletes an overwritten file's previously
published bytes from the asset store. A post-commit publish that fails is a warning on that
asset (and it is left out ofpublished), not a 500 over a committed composition.
Overwriting a never-published file is still destructive on failure; see docs 08.
Landed on branch1first (unreleased), merged up here.
- A batch op's failure is always that op's own result. An unsettable value (unparseable
- (#238)
POST pages/$ID/convertruns the class change and the publish in one transaction.
A refused publish now rolls the conversion back ((convert rolled back)in the message)
instead of leaving the draft converted behind a 403. A publishing convert reports what it
published inmeta.published.
Landed on branch1first (unreleased), merged up here. - (#241) A
publish: "recursive"composition response carriespublished: every record the
publish touched (publishSingle()targets, unchanged ones included), the page last. The publish covers the page's whole$ownstree, which can
include drafts the request did not write. The docs now say so.
Landed on branch1first (unreleased), merged up here. - (#241) Composition
prunereaches element children. For each relation an element entry
names underchildren, attached children the request did not write are archived (deleted if
unversioned), needdeletepluscanDelete(), and are reported inprunedwithrelation
andelement. An empty list removes every child under the relation. BC: a prune-enabled
composition that sends a partialchildrenlist now removes the omitted children; a relation
left out ofchildrenis untouched. Everychildrenkey must be a has_many of the element,
even with an empty list (422 UNKNOWN_RELATION); an empty list under any other name used to
be ignored. Children have
no ordering default; the docs now say so.
Landed on branch1first (unreleased), merged up here. - (#241) A stuck
$refqueue reportsUNRESOLVED_REFwhenever a wanted alias is declared
by no stuck element, andCIRCULAR_REFonly when every wanted alias is (a cycle, including a
self-reference). It used to pick the code by counting stuck entries. Both stay 422.
Landed on branch1first (unreleased), merged up here.
Fixed
-
(#277)
local-ci --strictis green with PHPStan anddev/buildas gates:phpstan-bootstrap.phpstops PHPStan with one actionable error whencambis/silverstan
isn't loaded (acomposer install --no-pluginsskips thephpstan/extension-installer
plugin that loads it), instead of reporting 55 SilverStripe config properties and extension
members as errors.tests/bootstrap.php(now thephpunit.xml.distbootstrap) drops the config cache the test
and live kernels share before the suite boots, so a livedev/buildrun first no longer
leaves a config without the TestOnly stubs (dozens of failures). It also finds the CMS
bootstrap in avendor/checkout. Docs 15's host command now passes-cwith the module's
config, which is what loads it (on branch2, keepSS_PHPUNIT_FLUSH=1with it).testAnUnknownEditorConfigNeverStoresScriptreads from the framework whether an unknown
editor config has element rules (later SS 5.x and SS6 give it the defaults) and pins the
matching outcome.
Landed on branch1first (unreleased), merged up here.
-
(#245) Setup and tooling:
- The install recipe (README, docs 00) adds the module's own VCS entry; it is not on
Packagist. - The request log and the population-gate warning go to
Psr\Log\LoggerInterface, which
ships with no handler; docs 02 gains a Logging section with a handler snippet, and the
module reports a handler-less logger once per process througherror_log(). GenerateContentApiExposureno longer suggests the external-id column,TopPage,
TopPageLocale,HasBrokenFile,HasBrokenLink,ReportClassor
framework-maintained relations (LinkTracking,FileTracking,BackLinks,
VirtualPages,Editor*/Viewer*) as writable (newExposureScaffolder.excluded_fields/
excluded_relations). Regenerated files lose those entries.CheckGrantExtensionReachabilityfollowsparent::canX()delegation when the class
declares a verbcanX()grants, so a project that exposesElementalAreawithupdate
no longer sees it reported as unreachable.CheckGrantExtensionReachabilityfollowsparent::canX()delegation, soElementalArea
is no longer reported as unreachable.
Landed on branch1first (unreleased), merged up here.
- The install recipe (README, docs 00) adds the module's own VCS entry; it is not on
-
(#244) Read paths:
GET records/$ClassRefon the draft stage primes the version-number cache, so its query
count no longer grows with the rows it visits.limitmust be a positive integer andoffseta non-negative one (400 PAYLOAD_INVALID
otherwise;limitis still capped atmax_limit). An empty filter value (Title=) matches
an empty orNULLtext field instead of being ignored; on any other field, or an unknown
one, it is now a 400/422 like a non-empty value.GET fingerprint'sincludeIdsmust be
1/0/true/false.GET fingerprint: related rows and violations report the row's own class; tied related rows
sort on every emitted field;externalIdcomes from the configured external-id column;
visibility is decided on the draft stage.
Behavior change:
limit=0and non-numeric or negative paging values are now a 400, and an
empty filter value now filters. Fingerprint output changes once for sites with related
subclasses, tied related rows or a custom external-id column.
Landed on branch1first (unreleased), merged up here. -
(#243) Spec and docs match the code:
- Branch
2only: the exposure task's hint and docs 00/15 usesake db:build --flush
instead of the SS5dev/build flush=1, andscripts/check-doc-drift.shnow flags the old
form. schema/endpoints.json(nowv1.18): has_one guidance moves fromrelations(which rejects
has_one names) tofields;relationsdescribes its real{mode, items}contract; the
genericCrudnote says an/apiwrite lands on the request's stage, usually LIVE; the
compositionpagegainselementsRelation.- Docs 04/10: a page under a draft-changed parent is unreadable on draft without
VIEW_DRAFT_CONTENTunless the grant extension answers for its own class, i.e. the class declares its ownread
access (now pinned by a test). - Docs 12: one warning-code table (seven codes, where each appears);
METHOD_NOT_ALLOWEDis
marked reserved, since a wrong verb gets a non-JSON 404 outside the envelope. Composition
children now report their write warnings (elements[].children[].warnings), which were
dropped. - Docs 02 gains
AssetService,FingerprintService,OwnedTreeWalkerandParityHandler
sections;summary.skippedis documented as always0; the page actions' population gate,
schema/siteand the stage-actionmodebody are in 05; no doc names a single-record write
endpoint that doesn't exist.
Landed on branch1first (unreleased), merged up here.
- Branch
-
(#242) Link payloads are part of their owner's write:
- The link is written to draft only and published with its owner, by any publish of the
owner. A"publish": "none"edit to a link on a published record no longer goes live at
once. - Links carry Owner fields (
OwnerClass,OwnerRelation,OwnerID), so linkfield's
permission checks defer to the owner and non-admin CMS editors can edit them. - The link write runs inside the operation's transaction, so a failed non-atomic batch op no
longer leaves a live orphan link. When the owner publishes, a link replaced by a type change
is archived. - In a publish cascade an owned link is gated on its owner's class
actionverb instead of
theLinkclass's (checked even for an owner the cascade finds unchanged), plus its own
canPublish(). A replaced link another record still points at (a duplicated owner's copy)
is never archived. A page convert re-stamps its links'OwnerClass.
Behavior change: a link edit made with
"publish": "none"waits for the owner to be
published.
Landed on branch1first (unreleased), merged up here. - The link is written to draft only and published with its owner, by any publish of the
-
(#240, #246) Asset uploads match, identify and report files the way SilverStripe stores them:
- The existing-file lookup uses the stored path (folder and file name filters, or an existing
folder's stored name), so re-uploading a name the filters rewrite finds the file instead of
creating a-v2sibling. - An
externalIdheld by a file at another path, or a path whose file has a different key,
is409 ASSET_CONFLICT(the code previously had no thrower), not a second file with the key
or a silent re-key. This covers every mode that writes a new file:rename, andskipwith
nothing at the path. urlis built without granting the request's session access, and a newvisibilityfield
says whether it ispublicorprotected.- Opt-in
AssetService.verify_content_typerefuses image-extension uploads whose bytes don't
sniff as an image (422 VALIDATION_FAILED). - Multipart uploads over PHP's limits are a new
413 PAYLOAD_TOO_LARGEnaming the limit, and
other PHP upload errors have accurate messages, instead of "requires a file part".
Behavior change: a renamed re-upload keeping its key, or a re-key of an existing file, is
now a 409.
Landed on branch1first (unreleased), merged up here. - The existing-file lookup uses the stored path (folder and file name filters, or an existing
-
(#240) External ids identify exactly one record:
- Every write that assigns one (
externalIdon create or update, andfields.FixtureIdentifier)
refuses a key already held anywhere in the class tree that carries the column, with409 ALREADY_EXISTSnaming the holder. Previously an update or afieldswrite could give a
second record the same key, after which everyext:lookup for it answered409 MULTIPLE_MATCHES. This also covers re-composing an element's id under a different class
(when the column is on a common ancestor such asBaseElement), which used to create a
second element that prune never removed. Re-sending a record's existing key is always fine,
and an empty key clears it. - A key containing
/,?,#or a control character is400 PAYLOAD_INVALID. ext:ids in the path are percent-decoded (ext:home%20page).- A composition
match.urlSegmentis normalised the way SiteTree stores it, so a re-run
matches the page it created. AcreateIfMissingsegment SiteTree would still rewrite
(admin→admin-2) is409 URLSEGMENT_COLLISIONinstead of a new page on every run.
Behavior change: re-key and backfill writes onto a key already in use now fail.
Landed on branch1first (unreleased), merged up here. - Every write that assigns one (
-
(#239) Silent successes on stage state:
unpublish,archiveand batchdeleteof a record whose live row is a different class
from its draft (a draft-only class conversion) refuse with a new409 STAGE_CLASS_MISMATCH.
They used to answer 200 with the old-class page still live, and archive also deleted the
draft row. A draft converted to an ancestor class is unaffected (Versioned finds that live
row).POST records/$ClassRef/$ID/publishwith{"mode": "none"}is400 PAYLOAD_INVALID
instead of a no-op answeringpublished.- The
stageblock reads Versioned (isOnDraft(),isModifiedOnDraft()): a live-only
record now reportsdraft: false, modifiedOnDraft: falseinstead of a hardcoded draft. - An unknown many_many
extraFieldskey is422 UNKNOWN_FIELDunderstrict(a warning,
and the key dropped, otherwise), instead of being dropped while the op reportedupdated.
A through relation's joinIDand foreign-key columns are never validextraFieldskeys,
so a client can no longer re-point a join row at a record it isn't authorized for.
Behavior change: callers relying on any of those 2xx answers now get the error.
Landed on branch1first (unreleased), merged up here. -
(#235) A has_one integer or
{"id": n}must name an existing record (404 NOT_FOUND),
as the{"externalId"}shape and has_many/many_many items already must;0still clears.
A dangling foreign key used to be stored and reported asupdated.
Landed on branch1first (unreleased), merged up here. -
(#246) Asset
conflict: skipis a true no-op, as documented. It no longer rewritestitle
orexternalIdon the existing record.
Landed on branch1first (unreleased), merged up here. -
(#233)
POST compositions/pagecan create new elements again when a project lists an
element's parent link inapi_protected_fields(ParentID/Parent, as the guarded-policy
setup does to keep it off the client surfaces). Previously every new element failed with
READONLY_FIELD: Field "ParentID" is not writable. A class'sapi_protected_fieldsnow
restricts client-supplied fields only. The trusted internal-fields channel (a composition
element's server-derivedParentID, and its defaultSort) is not blocked by it. The global
WriteApplicator.protected_fieldsfloor still applies to trusted writes. Client writes of a
field listed inapi_protected_fieldsare still blocked on every client surface (rejected by
batch and compositions, reverted on colymba/api).PermissionPolicy::buildCreateContext()
now prefers the trusted value over a client key for the same relation. Landed on branch1
first (unreleased), merged up here. -
(#234) The test suite now passes in a standalone module checkout, not only inside a host
project.ContentApiTestCase::getRequiredExtensions()applies the extensions 22 tests relied on
a host's exposure YAML for (ExternalIdentifierExtensiononFile/BaseElement,
ContentApiGrantExtensiononSiteTree). Test-only change; no runtime behavior changes. Landed
on branch1first (unreleased), merged up here.