Skip to content

2.9.0 — SilverStripe 6 line

Latest

Choose a tag to compare

@jsirish jsirish released this 18 Oct 16:11
· 1 commit to 2 since this release
e26fdfd

Behavior changes to check before upgrading (details in each entry below):

  • Composition prune also 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=0 and non-numeric/negative limit/offset are 400; an empty filter value (Title=) now filters.
  • PATCH records/$ClassRef/$ID is new and open on live by default; close it with RecordUpdateHandler.disabled_environments: [live].
  • Stricter authorization on compositions, assets, publish cascades, convert (new convert verb) 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/$ID with {"fields": {...}, "publish": "none|single"}:
    a single-record field update that needs CONTENT_API_ACCESS only and is not behind the
    population gate, so it works on a live site. Same checks as a batch update op (class verb on
    the record's own class, canEdit(), write policy and allowlist, action for publish: single); no creates or relations. It is open on live by default:
    RecordUpdateHandler.disabled_environments closes it per environment (403 ENV_FORBIDDEN,
    details[0].config names the key), and GET schema/site reports recordUpdateEnabled. See
    docs/en/04_security-model.md#live-writes-patch-recordsclassrefid for what it can reach. New
    content_records_update tool in schema/endpoints.json.
    Landed on branch 1 first (unreleased), merged up here.
  • (#231) Opt-in grant roots for ContentApiGrantExtension: grant_roots names ancestors
    (e.g. SiteTree, BaseElement) whose undeclared subclasses are granted the verbs they inherit;
    grant_exclude (shipping with ErrorPage, 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. New ClassRegistry::grantVerbs(); CheckGrantExtensionReachability reports the
    roots and exclusions and checks inherited grants. See docs/en/04_security-model.md#grant-roots-opt-in.
    Landed on branch 1 first (unreleased), merged up here.
  • (#207) Opt-in structured server-side request log (Dynamic\ContentApi\Logging\RequestLogger):
    one info entry per content-api/v1 request (endpoint, method, ClassRef/action, HTTP status,
    error code, duration, response byte size, authenticated member ID). Off everywhere by default;
    enabled_environments config (widen-only via project YAML, per #199) plus a force-ON-only
    SS_CONTENT_API_REQUEST_LOG override. A partially-failed POST batch carries its opFailures
    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 /api surface and a route matching no
    $url_handlers pattern 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 branch 1 first (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 (new PermissionPolicy::authorize()). See
    docs/en/04_security-model.md#composition-and-asset-paths for the full table.

    • Composition prune needs delete on every archived element plus canDelete(). All
      candidates are checked before any is archived.
    • A match-only composition (no page.fields) needs the page class update verb.
      createIfMissing needs the page class create verb. A parentId must exist (404 NOT_FOUND otherwise) and reaches canCreate()'s context as Parent.
    • POST assets and composition assets[] share one authorization
      (AssetService::authorizeIngest()). A new file needs create plus canCreate() with its
      folder as Parent (and Folder::canCreate() for a missing folder). overwrite needs
      update plus canEdit() on the existing file, found on the draft stage. skip needs
      read. Publishing needs action.

    Behavior changes:

    • An asset upload that does not send publish, on a class without action, is now written
      draft-only with an ACTION_VERB_MISSING warning in data.warnings; it used to publish. An
      explicit "publish": true without action is 403 FORBIDDEN_CLASS.
    • A composition assets[] entry without its own publish now follows the composition: it is
      draft-only under publish: "none" (it used to publish).
    • Composition asset ingest, overwrite and prune now need the verbs above, where they
      previously needed none of them.
      Landed on branch 1 first (unreleased), merged up here.
  • (#239) An asset publish value is parsed strictly, in JSON and multipart alike: the string
    "false" no longer reads as true, and anything other than a boolean, 1/0 or
    "true"/"false"/"1"/"0" is 400 PAYLOAD_INVALID.
    Landed on branch 1 first (unreleased), merged up here.

  • (#226) Publish mode recursive (POST records/$Class/$ID/publish and a payload publish
    key) now checks every $owns-reachable record publishRecursive() will publish: each needs
    the class action verb and the record check, the same walk and gate owns mode 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,
    since publishRecursive() leaves it untouched. Behavior change: a recursive publish
    reaching a changed owned record of a class without action (a draft-only image on a
    read/create-only File, say) is now 403. The walk reads the draft stage, so an owned
    record that exists only on Live (deleted from draft) and that publishRecursive() removes
    from Live is not checked.
    Landed on branch 1 first (unreleased), merged up here.

  • (#235) Publish and unpublish now honour a model's canPublish()/canUnpublish() (new
    PermissionPolicy::checkStageAccess()), falling back to canEdit() 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 delete in unpublish mode, and
      archive/hard on a published record (the CMS's canArchive() asks canUnpublish()
      too);
    • asset publishes of an existing file.

    Behavior change: a class or extension that allows edit but denies publish or unpublish now
    gets 403 FORBIDDEN_RECORD for those operations. ContentApiGrantExtension defines no
    canPublish(), so service accounts it covers publish on its canEdit() grant. The exception
    is a model that overrides canPublish() directly without extendedCan(), where the model's
    own rule now applies.
    Landed on branch 1 first (unreleased), merged up here.

  • (#228) Changing a page's class (POST pages/$ID/convert and a composition's
    page.convertTo) needs a new bare convert verb in api_access on both the page's current
    class and the target class, in addition to update (and action when publishing). It was
    previously allowed between any two classes granting update. api_access: true includes
    convert; a CSV list must name it ('GET,POST,PUT,action,convert'). Behavior change:
    projects that convert pages must add convert to the classes involved.
    Landed on branch 1 first (unreleased), merged up here.

  • (#235) apply-template needs the page class update verb (not just canEdit()),
    canView() on the template, and the create verb plus canCreate() on every class the apply
    will create: each template element's class and every child class duplicate() cascades to.
    All are checked before anything is written.
    Landed on branch 1 first (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_many set clears, needs the update verb plus canEdit()
    (its foreign key is written); a many_many item needs read plus canView(). They previously
    checked only the owning record. Behavior change: projects writing has_many relations need
    update on the child class.
    Landed on branch 1 first (unreleased), merged up here.

  • (#225) HTML-typed field writes (DBHTMLText/DBHTMLVarchar) are sanitised with
    HTMLEditorSanitiser and the cms editor 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 with update on 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> and on* attributes, that includes, under the stock cms rules: 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-*, srcset and loading. A module write that lost markup carries a new
    VALUE_SANITISED warning. New config WriteApplicator.sanitise_html (default true) turns
    sanitising off, as does the CMS's own HTMLEditorField.sanitise_server_side: false;
    html_editor_config (default cms) 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 branch 1 first (unreleased), merged up here.

  • (#230) colymba's /api writes drop any payload key that isn't a schema name for the class
    (a DB column, a has_one or its ID/Class/Relation column, a many relation,
    ManyManyExtraFields). colymba assigns every key with $model->{$key} = $value, which runs a
    matching set{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 branch 1 first (unreleased), merged up here.

  • (#235) colymba's own relation write (removeAll() then add() 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 is 403.
    Landed on branch 1 first (unreleased), merged up here.

  • (#237) colymba's unauthenticated api/auth/logout and api/auth/lostPassword are off by
    default; login stays on. ColymbaAuthHardening.auth_actions (a string-keyed map project
    YAML can flip) is applied to colymba's allowed_actions by a new module _config.php. The
    ?token= query-var fallback on /api is closed by renaming TokenAuthenticator.tokenQueryVar.
    Behavior change: logout/lostPassword callers get 403, and /api clients passing
    ?token= must send the X-Silverstripe-Apitoken header (or set tokenQueryVar: token).
    Landed on branch 1 first (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 (and meta.total), stage actions
      (including the forced-unpublish delete check), parity and upsert-to-existing all check
      the record's own class. Asset endpoints resolve the nearest class that declares access,
      so an explicitly denied Image is no longer governed by File's grant.
    • List filters and sorts accept only columns a response can show (api_fields, emitted
      has_one FKs, ID, the external-id column, minus hidden_fields). A withheld column used to
      leak its value through which rows matched.
    • GET schema/$ClassRef refuses 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
    403 and list totals drop; filtering or sorting on a never-emitted column returns 422
    (ClassName, ID and the external-id column stay filterable). A project that set
    Image: api_access: false meaning "no opinion, use File's" must remove the key instead: an
    explicit false on an asset class is now a deny.
    Landed on branch 1 first (unreleased), merged up here.

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) is 422 INVALID_VALUE, and any other exception is
      a per-op SERVER_ERROR. Either used to escape as a 500 that dropped every result and left
      earlier non-atomic ops committed.
    • Archive, unpublish and non-versioned delete (batch delete op and the archive action) run
      in a transaction, so a hook that throws mid-archive no longer leaves a record unpublished but
      still on draft.
    • subtree and owns publish 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 of published), not a 500 over a committed composition.
      Overwriting a never-published file is still destructive on failure; see docs 08.
      Landed on branch 1 first (unreleased), merged up here.
  • (#238) POST pages/$ID/convert runs 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 in meta.published.
    Landed on branch 1 first (unreleased), merged up here.
  • (#241) A publish: "recursive" composition response carries published: every record the
    publish touched (publishSingle() targets, unchanged ones included), the page last. The publish covers the page's whole $owns tree, which can
    include drafts the request did not write. The docs now say so.
    Landed on branch 1 first (unreleased), merged up here.
  • (#241) Composition prune reaches element children. For each relation an element entry
    names under children, attached children the request did not write are archived (deleted if
    unversioned), need delete plus canDelete(), and are reported in pruned with relation
    and element. An empty list removes every child under the relation. BC: a prune-enabled
    composition that sends a partial children list now removes the omitted children; a relation
    left out of children is untouched. Every children key 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 branch 1 first (unreleased), merged up here.
  • (#241) A stuck $ref queue reports UNRESOLVED_REF whenever a wanted alias is declared
    by no stuck element, and CIRCULAR_REF only 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 branch 1 first (unreleased), merged up here.

Fixed

  • (#277) local-ci --strict is green with PHPStan and dev/build as gates:

    • phpstan-bootstrap.php stops PHPStan with one actionable error when cambis/silverstan
      isn't loaded (a composer install --no-plugins skips the phpstan/extension-installer
      plugin that loads it), instead of reporting 55 SilverStripe config properties and extension
      members as errors.
    • tests/bootstrap.php (now the phpunit.xml.dist bootstrap) drops the config cache the test
      and live kernels share before the suite boots, so a live dev/build run first no longer
      leaves a config without the TestOnly stubs (dozens of failures). It also finds the CMS
      bootstrap in a vendor/ checkout. Docs 15's host command now passes -c with the module's
      config, which is what loads it (on branch 2, keep SS_PHPUNIT_FLUSH=1 with it).
    • testAnUnknownEditorConfigNeverStoresScript reads 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 branch 1 first (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 through error_log().
    • GenerateContentApiExposure no longer suggests the external-id column, TopPage,
      TopPageLocale, HasBrokenFile, HasBrokenLink, ReportClass or
      framework-maintained relations (LinkTracking, FileTracking, BackLinks,
      VirtualPages, Editor*/Viewer*) as writable (new ExposureScaffolder.excluded_fields/
      excluded_relations). Regenerated files lose those entries.
    • CheckGrantExtensionReachability follows parent::canX() delegation when the class
      declares a verb canX() grants, so a project that exposes ElementalArea with update
      no longer sees it reported as unreachable.
    • CheckGrantExtensionReachability follows parent::canX() delegation, so ElementalArea
      is no longer reported as unreachable.
      Landed on branch 1 first (unreleased), merged up here.
  • (#244) Read paths:

    • GET records/$ClassRef on the draft stage primes the version-number cache, so its query
      count no longer grows with the rows it visits.
    • limit must be a positive integer and offset a non-negative one (400 PAYLOAD_INVALID
      otherwise; limit is still capped at max_limit). An empty filter value (Title=) matches
      an empty or NULL text 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's includeIds must 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; externalId comes from the configured external-id column;
      visibility is decided on the draft stage.

    Behavior change: limit=0 and 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 branch 1 first (unreleased), merged up here.

  • (#243) Spec and docs match the code:

    • Branch 2 only: the exposure task's hint and docs 00/15 use sake db:build --flush
      instead of the SS5 dev/build flush=1, and scripts/check-doc-drift.sh now flags the old
      form.
    • schema/endpoints.json (now v1.18): has_one guidance moves from relations (which rejects
      has_one names) to fields; relations describes its real {mode, items} contract; the
      genericCrud note says an /api write lands on the request's stage, usually LIVE; the
      composition page gains elementsRelation.
    • Docs 04/10: a page under a draft-changed parent is unreadable on draft without
      VIEW_DRAFT_CONTENT unless the grant extension answers for its own class, i.e. the class declares its own read
      access (now pinned by a test).
    • Docs 12: one warning-code table (seven codes, where each appears); METHOD_NOT_ALLOWED is
      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, OwnedTreeWalker and ParityHandler
      sections; summary.skipped is documented as always 0; the page actions' population gate,
      schema/site and the stage-action mode body are in 05; no doc names a single-record write
      endpoint that doesn't exist.
      Landed on branch 1 first (unreleased), merged up here.
  • (#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 action verb instead of
      the Link class'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 branch 1 first (unreleased), merged up here.

  • (#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 -v2 sibling.
    • An externalId held by a file at another path, or a path whose file has a different key,
      is 409 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, and skip with
      nothing at the path.
    • url is built without granting the request's session access, and a new visibility field
      says whether it is public or protected.
    • Opt-in AssetService.verify_content_type refuses 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_LARGE naming 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 branch 1 first (unreleased), merged up here.

  • (#240) External ids identify exactly one record:

    • Every write that assigns one (externalId on create or update, and fields.FixtureIdentifier)
      refuses a key already held anywhere in the class tree that carries the column, with 409 ALREADY_EXISTS naming the holder. Previously an update or a fields write could give a
      second record the same key, after which every ext: lookup for it answered 409 MULTIPLE_MATCHES. This also covers re-composing an element's id under a different class
      (when the column is on a common ancestor such as BaseElement), 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 is 400 PAYLOAD_INVALID.
    • ext: ids in the path are percent-decoded (ext:home%20page).
    • A composition match.urlSegment is normalised the way SiteTree stores it, so a re-run
      matches the page it created. A createIfMissing segment SiteTree would still rewrite
      (admin → admin-2) is 409 URLSEGMENT_COLLISION instead of a new page on every run.

    Behavior change: re-key and backfill writes onto a key already in use now fail.
    Landed on branch 1 first (unreleased), merged up here.

  • (#239) Silent successes on stage state:

    • unpublish, archive and batch delete of a record whose live row is a different class
      from its draft (a draft-only class conversion) refuse with a new 409 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/publish with {"mode": "none"} is 400 PAYLOAD_INVALID
      instead of a no-op answering published.
    • The stage block reads Versioned (isOnDraft(), isModifiedOnDraft()): a live-only
      record now reports draft: false, modifiedOnDraft: false instead of a hardcoded draft.
    • An unknown many_many extraFields key is 422 UNKNOWN_FIELD under strict (a warning,
      and the key dropped, otherwise), instead of being dropped while the op reported updated.
      A through relation's join ID and foreign-key columns are never valid extraFields keys,
      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 branch 1 first (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; 0 still clears.
    A dangling foreign key used to be stored and reported as updated.
    Landed on branch 1 first (unreleased), merged up here.

  • (#246) Asset conflict: skip is a true no-op, as documented. It no longer rewrites title
    or externalId on the existing record.
    Landed on branch 1 first (unreleased), merged up here.

  • (#233) POST compositions/page can create new elements again when a project lists an
    element's parent link in api_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's api_protected_fields now
    restricts client-supplied fields only. The trusted internal-fields channel (a composition
    element's server-derived ParentID, and its default Sort) is not blocked by it. The global
    WriteApplicator.protected_fields floor still applies to trusted writes. Client writes of a
    field listed in api_protected_fields are 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 branch 1
    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 (ExternalIdentifierExtension on File/BaseElement,
    ContentApiGrantExtension on SiteTree). Test-only change; no runtime behavior changes. Landed
    on branch 1 first (unreleased), merged up here.