NewYour coding agent can read the release notes before it upgrades.Set up the MCP server →
Packagist · #3946 most downloaded on Packagist
Secure secrets management for TYPO3 with envelope encryption, access control, and audit logging
Last release 8 days ago
29 Sep 2026
Ships fairly regularly
a new release about every 2 weeks
Nearly every release is documented
notes for 36 of 36 stable releases
Nothing withdrawn
no release was ever pulled
9 months old
36 releases · first in 2026
One column per month.
…the TYPO3 light and dark scheme. There are no breaking changes.
nr-vault 1.1.0 adds a streaming HTTP send that keeps the SSRF protections of the vault HTTP client, removes the two deprecations nr_vault triggers on TYPO3 v14, and makes the backend module follow the TYPO3 light and dark scheme. There are no breaking changes.
supportsStreaming(); see ADR-039.typo3/cms-install is suggested instead of required (#398, @CybotTM). Nothing needs it at runtime, and a hard requirement stopped functional tests of consuming extensions that load nr_vault without EXT:install. The upgrade wizard is still offered on every installation.VaultFieldHelper hold secret identifiers and are now marked not searchable; TYPO3 v13 ignores the flag.composer.json declares the extension version and providesPackages, as TYPO3 #108345 requires.Full changelog: v1.0.1...v1.1.0
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-1.1.0.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-1.1.0.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
VaultHttpClient implements the new calling interface StreamingHttpClientInterface: sendStreaming() returns once the origin's response head and the first body bytes have arrived, and reading the body drives the transfer, so a caller sees a provider's streamed answer as it arrives instead of at the end. The transfer runs on the curl-multi transport of sendCancellable() with the CURLOPT_RESOLVE pin, the SSRF middleware, the host allowlist and the same credential injection; Guzzle's stream option, which bypasses the pin, is never set. Redirects are returned, not followed, and a tunnelling proxy's 200 Connection established is never returned as the response. At most 16 MiB of unread body is buffered; a step that delivers more, such as a small gzip body that decodes to hundreds of MiB, fails with its own message instead of filling memory. A transfer that fails after the headers throws from read() instead of ending as a short body, a stalled one ends — at the transfer timeout, or, when no total timeout is configured (timeout = 0), after 60 seconds without a final head or body bytes (interim 1xx heads do not count), so a stream that keeps delivering is not cut off — and closing or dropping the body, or the cancellation signal, removes the transfer from the transport. getContents() and __toString() return at most 16 MiB and throw past that; __toString() throws instead of returning a partial body, a deliberate deviation from PSR-7. Each call writes exactly one audit row when sendStreaming() returns or throws: http_call when it returns or fails, http_call_cancelled when the signal stops the transfer before it returns, http_call_cancelled_before_send when the signal was already set on entry. A failure or a cancellation while the body is read writes no second row; the body is never logged. Consumers feature-detect with instanceof and supportsStreaming(). See ADR-039.typo3/cms-install moved from require to suggest (and require-dev). With version and providesPackages declared, TYPO3 v14 and the testing framework read nr_vault's dependencies from composer.json instead of ext_emconf.php, so a require on typo3/cms-install would make EXT:install a hard dependency and stop every functional test that loads nr_vault without it (Package "nr_vault" depends on package "install" which does not exist), here and in consuming extensions. Nothing needs it at runtime: the TYPO3 14 upgrade wizard uses the upgrade API in EXT:core, and the TYPO3 13 wizard shell is registered only when EXT:install's interface exists (Configuration/Services.php), the way the core's own EXT:extensionmanager ships its v13 wizards with a require on typo3/cms-core alone. EXT:install is a protected core extension, so every installation still offers the wizard.VaultFieldHelper are no longer searched by the TYPO3 v14 backend search. getFieldConfig(), getSecureFieldConfig() and addVaultFields() set 'searchable' => false in the column config. TYPO3 v14 searches every input column that does not opt out, so on a consumer table that no longer sets ctrl.searchFields (v14 removed it; its migration marks only the columns a still-present list leaves out) the backend search included the vault columns, which hold secret identifiers. TYPO3 v13 ignores the flag; there the consumer's ctrl.searchFields still decides.#2f99a4 with black text in both schemes. They now use core's badge-info and badge-primary, so they follow the backend scheme like every other badge, and the brand colour no longer appears in module content. The module icons keep their teal accent.ext_emconf.php deprecation for nr_vault. composer.json declares extra.typo3/cms.version and an empty Package.providesPackages (TYPO3 #108345: an extension that still ships ext_emconf.php must name both). The version is now stated in ext_emconf.php, composer.json and Documentation/guides.xml; VersionConsistencyTest fails when a release commit misses one of them.tx_nrvault_secret. The table no longer sets ctrl.searchFields, which v14 removed (#106972) and strips with a deprecation. The backend search scope is unchanged: v14 searches identifier, description and context through the per-column searchable flag, now set to false on the five other searchable columns (expires_at, metadata, last_rotated_at, last_read_at, adapter) exactly as the automatic migration did, and v13, which ignores that flag, gets searchFields from a TCA override that applies on v13 only.sendCancellable() no longer aborts a call that is still receiving when no total timeout is set (#394). With timeout = 0, the default on TYPO3 13.4 and 14.3, its tick loop applied a wall-clock budget of connect_timeout + 5 s (15 s with the default connect_timeout of 10), so every cancellable call that took longer failed with Cancellable transfer exceeded its wall-clock budget and was aborted, although sendRequest() completed the same call. Without a total timeout the cancellable send and its OAuth token leg now bound silence instead of duration, as sendStreaming() does: they end when nothing — no final response head, no body byte after one — has arrived for 60 seconds, with Cancellable transfer received nothing within its idle limit and was aborted (audited as http_call with success = false) or Cancellable OAuth token transfer received nothing within its idle limit and was aborted. Interim 1xx heads do not count. With a total timeout nothing changes. The whole body is still returned, with no size limit. See ADR-040.BackendModuleValidator reroutes every route of a second-level module that has submodules to one of those submodules, and the Help tab and the overview's "see help" link pointed at admin_vault.help on the admin_vault parent. Both now use admin_vault_overview.help on the overview submodule, whose routes core leaves alone. The Dashboard tab and the Help page's dashboard link, which reached the overview only through VaultOverviewModuleResolver, point at admin_vault_overview as well. TYPO3 14 was not affected. A bookmark of the old /module/admin/vault/help URL is redirected to the help page on both versions.stages attribute was cut off by an escaped quote inside a single-quoted HTML attribute, so core's <typo3-backend-progress-tracker> received invalid JSON and threw "Cannot read properties of null (reading 'length')" on every wizard step, on TYPO3 13.4 and 14.3. The labels are now encoded with f:format.json in one shared partial, and each step passes its 1-based position; the steps were numbered from 0, so each would have shown the previous step's name. The "Execute" stage label is translated like the other four.<img>, so the currentColor glyph painted black on the dark card (dominant ink #080808, 1.18:1, measured from a screenshot of the icon on TYPO3 14.3.7); they are inlined now. Badges use core's badge-* variants instead of fixed colours, notices use core callout markup instead of Bootstrap .alert, and the vaultSecret field's buttons use btn-default instead of the dark btn-secondary tile..progress, which TYPO3 14 does not ship, so no bar was drawn; they are native <progress> elements now, styled with core design tokens in every engine, each with an accessible name and the count and percentage beside it; that visible text is the bar's aria-describedby description, because Chromium drops aria-valuetext on a native <progress>. The Help FAQ used Bootstrap's accordion, which neither TYPO3 13.4 nor 14.3 styles, and is a native <details> list now. Loading indicators use core's <typo3-backend-spinner> instead of Bootstrap's .spinner-border.vaultSecretInput field's hide button hides the secret. After a reveal, a click on it fetched and showed the secret again, because the reveal listener was never removed: removeEventListener() was given the unbound method, not the bound function addEventListener() had received. One listener now reads the button's mode.<f:asset.script useNonce="true">, whose useNonce argument TYPO3 14.3 deprecates in favour of csp, an argument 13.4 does not have. MigrationReview.js is loaded through the backend import map on both versions and needs no nonce.text-body-secondary class, which core does not define, is replaced by text-variant; the CLI table on the overview drops table-sm and uses core's default density (12px cells instead of 11px), a density choice: core defines table-sm but uses it in only one 14.3 template; the site-configuration usage card names an icon that exists in core instead of rendering the "not found" glyph; the icon-only buttons of the vaultSecret and vaultSecretInput fields take their title and aria-label from the translation files instead of fixed English text, and the vaultSecretInput reveal button is named "Hide secret" while the value is shown; the health-check heading no longer skips from h1 to h4; the FAQ items stay mutually exclusive through a shared <details name>; layout classes are removed where core's backend CSS has no rule for them at 14.3 (btn-lg, align-baseline, rounded, the last of which overrode the token radius on 13.4) or at either version (card-link, card-has-hover, card-text, form-actions, pagination-wrap).nr-vault 1.0.1 closes one gap in the SSRF guard and fixes the type of one DataHandler hook. There are no breaking changes.
nr-vault 1.0.1 closes one gap in the SSRF guard and fixes the type of one DataHandler hook. There are no breaking changes.
64:ff9b:1::/48 (RFC 8215) (#388, @CybotTM). Until now only the well-known prefix 64:ff9b::/96 was refused. On a network whose NAT64 gateway uses the local-use prefix, an address such as 64:ff9b:1:a9fe:a9:fe00:: reached the cloud metadata address 169.254.169.254.SecretTcaHook::processDatamap_preProcessFieldArray() takes ?array &$fieldArray. The hook refuses a record by setting the array to null, as documented, and the native array type contradicted that. A field array that another hook has already set to null is left alone.ConnectionPool in tests are recorded (#383).Full changelog: v1.0.0...v1.0.1
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-1.0.1.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-1.0.1.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
64:ff9b:1::/48 (RFC 8215). Only the well-known prefix 64:ff9b::/96 was refused, so on a network whose NAT64 gateway uses the local-use prefix an address like 64:ff9b:1:a9fe:a9:fe00:: reached 169.254.169.254.SecretTcaHook::processDatamap_preProcessFieldArray() takes ?array &$fieldArray. The hook refuses a record by setting the array to null, as documented; the native array type contradicted that, and PHPStan 2.2.15 reports every test of it as impossible. A field array another hook already nulled is left alone.Six interfaces are marked as extension points, and a new method on one of them counts as a breaking change; an API-surface snapshot test holds the lin…
First stable release. ext_emconf.php moves to state = stable and the composer branch alias to 1.x-dev; SECURITY.md names 1.x as the supported line from here on, and 0.x receives no further fixes.
The SSRF guard now refuses a host it cannot resolve to a checked address. If a vault endpoint, OAuth token endpoint or audit webhook collector is served by /etc/hosts, by an NSS module or by a container runtime's embedded resolver rather than by DNS, list it literally in $GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts'] — the same opt-in the guard already documents for private addresses. Public addresses are no exception: what matters is whether dns_get_record() can see the name. The rejection message names the setting, and it is worded differently from the disallowed-range rejection so a typo in a URL cannot be mistaken for a rebinding attempt.
dns_get_record() speaks DNS and nothing else, while the HTTP transport resolves through getaddrinfo(), which also reads /etc/hosts, NSS modules and mDNS. An empty answer therefore meant "no address was checked", and the request went out for the transport to resolve and connect to whatever came back. Both halves of the guard now fail closed — the caller-side isHostAllowed() gate and the ssrf-dns-pin middleware — and an answer whose records are not parseable addresses counts as no answer. Recorded in ADR-038.$GLOBALS['TYPO3_CONF_VARS']['HTTP'] is operator input, and proxy, verify, cert, ssl_key and allow_redirects were passed through whatever their type. Guzzle tests verify === false strictly, so a value like "0" disabled nothing while looking as though it did. Each of the five is narrowed at the boundary, and an unrecognised value is dropped rather than mapped to the permissive reading of it.vault:doctor notices a half-restored database (#376, #380). Its controls read the master-key provider, the profile, CLI access and the audit chain; none of them read tx_nrvault_secret or the two permission tables, so a restore that brought back the audit log without the secrets reported ready. Two comparisons under a new inventory group close the half that is visible from the data — identifiers the audit log records as created with no secret row, and permission rows whose secret is gone. Both report counts rather than identifiers, because the JSON report travels into CI logs and an identifier names a credential. Both read zero against zero on a fresh installation, so an empty vault stays silent.MasterKeyProviderInterface was published as an extension point while the factory resolved a closed list of four identifiers. Providers are collected by the service tag nr_vault.master_key_provider; a duplicate identifier is refused rather than resolved by load order, so an installed extension cannot take over the key source by claiming the name file.vault:rotate-master-key handles a vault that holds only an audit chain (#346), and the HMAC audit-chain upgrade wizard reaches TYPO3 13 and 14 (#345) — it was tagged in a way that kept it from being registered at all.ci.yml and release-evidence.yml at the tagged commit. This release's bundle records 5913 tests across three suites, 95.96% line and 87.36% branch coverage, a mutation score of 87.70% over the security-critical scope, PHPStan level 10, and a dependency audit with no advisories — with no entry marked fail and none marked absent.No independent external security audit has taken place. The evidence bundle above is self-measurement: it records what the project's own tests, coverage, mutation and audit tooling reported for this commit, and nothing beyond that. Documentation/Security/KnownLimitations.rst states the boundaries that remain.
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-1.0.0.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-1.0.0.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
A custom master-key provider can finally be selected.
MasterKeyProviderInterface was published as an extension point, but the
factory resolved a closed list of four identifiers, so a provider shipped by
another extension could never be configured — the developer chapter showed a
KmsKeyProvider example that could not work while the key-custody chapter
said the opposite. Providers are collected by the service tag
nr_vault.master_key_provider and indexed under the identifier each one
reports. A duplicate identifier is refused rather than resolved by load
order, so an installed extension cannot take over the key source by claiming
the name file; a blank identifier is refused; and the ambiguity check runs
before the standard-profile fallback that would otherwise auto-detect a
different key source. The hardened profile denies typo3 and nothing else,
and auto-detection still probes only the built-in local sources, so a vault
never adopts an external custody nobody configured.
The E2E suite runs in CI against both TYPO3 lines. A repository workflow
provisions a real installation per matrix cell — TYPO3 13.4 on PHP 8.2 and
14.3 on PHP 8.5, MariaDB, a web server, chromium — and runs the Playwright
specs against it. Build/Scripts/e2e-provision.sh builds the same instance
locally.
A written compatibility promise for implementers. Six interfaces are
extension points — meant to be implemented by other extensions, not only
called: VaultAdapterInterface, MasterKeyProviderInterface,
AuditSinkInterface, ForeignEnvelopeRotatorInterface,
ReadinessCheckInterface and CancellationSignalInterface. They carry the
new #[Netresearch\NrVault\Attribute\ExtensionPoint] attribute, and the API
chapter lists them under "Extension points and the compatibility promise":
an extension point gains no method and changes no signature outside a major
release; every other interface is for calling and may gain methods in a
minor one.
vault:doctor notices a half-restored database. Its controls read the
master-key provider, the security profile, CLI access and the audit chain,
and not one of them read tx_nrvault_secret or the two permission tables —
so a restore that brought back the audit log without the secrets, or the
secrets without their permission rows, reported ready. Two comparisons under
a new inventory group close the half that is visible from the data:
inventory.missing_secrets is critical and counts the identifiers the audit
log records as created that have no secret row; inventory.orphan_permissions
warns about permission rows whose secret is gone. Neither finding carries an
identifier — the report travels into CI logs, and an identifier names a
credential — so both report counts. Both read zero against zero on a fresh
installation, so an empty vault stays silent, and the restore documentation
now says which half is covered and which is not.
A pre-release check without publishing. A manual run of
release-evidence.yml with a commit SHA as ref runs every check against
that commit and publishes a bundle labelled precheck-<commit>, whose
release-identity entry reads "not a tagged build" instead of a tag mismatch —
the way to vet a release candidate, since the release pipeline accepts only
x.y.z tags. Manual runs also record the checked-out commit now, not the head
of the branch the run was started from.
The platform's transport settings reach Guzzle only in the shapes it acts
on. $GLOBALS['TYPO3_CONF_VARS']['HTTP'] is operator input, and five of
its keys — proxy, verify, cert, ssl_key and allow_redirects —
were passed through whatever their type. Guzzle tests verify === false
strictly, so a value like "0" or 0 disabled nothing while looking as
though it did; a malformed proxy or certificate entry reached the
transport unchecked. Each of the five is narrowed at the boundary now, and
an unrecognised value is dropped rather than mapped to the permissive
reading of it.
The test suites are strict about their own noise. failOnNotice,
failOnPhpunitDeprecation, failOnEmptyTestSuite and
beStrictAboutOutputDuringTests are on for both suites, failOnRisky for
the functional one, and failOnDeprecation plus random execution order for
the unit suite; the 264 PHPUnit deprecations and 50 notices that stood
behind them are gone.
The architecture rules actually run. The phpat test was registered without the tag that makes PHPStan execute it, so none of its rules had ever fired. They run now, and the violations they reported are either fixed or excluded by name with a stated reason.
The release evidence measures branch and path coverage and reports functional coverage. Line coverage over the merged unit and functional runs is 95.67 % (bar 93), branch coverage 87.14 % (bar 85), and the mutation score 78.42 % (bar 77) once the demo seeder and the string-concatenation mutants in prose-building code are out of the denominator. A coverage report assembled from fewer parts than were planned is no longer published at all: the bundle records the coverage checks as absent and the job fails, rather than presenting a partial measurement as a whole one.
A release is published only after its evidence passed. release.yml
now starts with the fleet release-gate.yml, which waits for ci.yml and
release-evidence.yml to succeed at the tagged commit; the build, the
signatures, TER, Packagist and the GitHub release all depend on it. Until now
the evidence ran in parallel with the release, so a failed check was only
visible next to a release that was already out. A FAIL evidence verdict
now stops the release; DEGRADED does not.
Support policy for 1.0. SECURITY.md names 1.x as the supported line
from the 1.0.0 release on; 0.x receives fixes until then and none afterwards.
The Composer branch alias of main moves from the stale 0.5.x-dev to
1.x-dev.
Duplicating a record no longer damages its vault secrets. Copying, translating, copying into a language or synchronising an inline child writes the new record through an inner save pass in which the vault field carries the SOURCE record's identifier as a plain string — indistinguishable from a freshly typed secret. A copy therefore left behind a secret whose plaintext was an identifier, a translation ended up referencing that damaged secret, and a copied inline child shared the source child's secret outright, so rotating the copy changed the original and deleting the copy destroyed it. Every duplicating command now gives each new record its own clone, for TCA fields and FlexForm fields alike.
A translation shares the default record's secret instead of forking it.
A vault field is l10n_mode = exclude, so TYPO3 synchronises it into every
translation whenever the default-language record is saved. That path created
a secret whose plaintext was an identifier, and deleting a translation
deleted the credential the default record still used. The translation keeps
the default record's identifier, and a secret is deleted only when no live
record still references it.
Revealing a secret no longer blocks the backend on TYPO3 13. The list
opened a loading dialog for the AJAX call and dismissed it as soon as the
response arrived. TYPO3 13 renders a modal with Bootstrap, which ignores
hide() while the show transition is still running, so the dialog and its
backdrop stayed in the document — and that backdrop covers the whole
backend, module iframe included. After one reveal, rotating, toggling or
deleting a secret was impossible until the page was reloaded. The reveal
flow opens no loading dialog at all now; the button carries the wait.
The revealed plaintext is wiped on TYPO3 13 as well. The wipe ran when the dialog reported itself closed, but TYPO3 13 takes the dialog out of the document while it closes and puts an element with the same content back afterwards, so the wipe found no field and the value stayed readable in the page. It now runs at the start of the close, while the field is still reachable.
Rotating a secret from the list works again. The dialog is rendered in the outer backend document while its handler looked the input up inside the module iframe, so the field was never found: the dialog reported a missing value and no rotation request was ever sent.
An invalid secret identifier is refused instead of silently rewritten.
TCA evaluation stripped everything but letters and digits, so a value like
<script>alert(1)</script> was stored as scriptalert1script — a record
under an identifier nobody chose. The write is rejected and audited; records
already stored under such an identifier stay readable and deletable.
The TYPO3 13 parent module opens the vault overview instead of whichever
submodule the user opened last. Only the parent's own path does: every route
of a module carries the same module identifier, so matching on that alone
also caught the parent's help route and opening Help rewrote the submodule
the backend remembers for that user.
State badges meet the WCAG AA contrast minimum on both TYPO3 lines; the success badge measured 3.6:1 before.
The backup procedure names every table a restore needs. The two
many-to-many tables holding per-secret group permissions were missing, so a
restore that followed the documentation produced decryptable secrets and a
verifying audit chain — and then refused a user whose group had access. The
sys_registry entries for the audit anchor and the break-glass session, and
the scheduler task rows, were missing for the same reason.
The API snapshot check called a new interface method harmless. It classified every added method as additive and told the author to regenerate the snapshot, because no caller breaks. A method added to an interface that extensions implement breaks every implementation instead. The check now reads the extension-point mark from the snapshot: an added method, or a changed one — optional parameter included — on an extension point is reported as breaking under "breaks implementers", gaining the mark is additive and losing it is breaking. One sample implementation per extension point, written against the published interface only, makes such a change fail the unit run and PHPStan the way it would fail a real consumer, and a test holds the documented list to the attribute.
vault:rotate-master-key left the audit history of an empty vault on the
old key. The inventory counted secrets and consumer-owned envelopes only, so
a vault whose secrets had all been deleted reported "No secrets found" and
exited successfully — before the audit-chain re-key. The chain and its tip
anchor are keyed from the master key too; after the configuration switch they
would have verified only under the key the operator had just been told to
destroy. The inventory now also counts audit rows sealed under a
master-key-derived HMAC key (epoch 1 and up) and the stored chain-tip anchor,
and an audit-only vault goes through the same verified, transactional re-key
as any other. The dry run reports the row count, and a failed re-key rolls
back as a whole.
The HMAC audit-chain upgrade wizard never reached TYPO3. It was tagged
upgrade.wizard, a name neither TYPO3 13 nor 14 reads — both fill their
wizard registry from install.upgradewizard — so neither the Install Tool
nor upgrade:run ever offered nrVaultAuditHmacMigration. The tests built
the wizard with new and could not notice. On TYPO3 13 it could not have run
in any case: it implemented TYPO3 14's interface from EXT:core, which 13 does
not have, and its tests and PHPStan skipped that major.
The logic now lives in the version-neutral AuditHmacMigration. Two thin
shells implement the upgrade API of each major, and Configuration/Services.php
registers the one the running core provides. A functional test asks TYPO3's
own registry for the wizard and runs a migration through it, on both majors.
PHPStan analyses both shells on every matrix leg instead of excluding the
wizard: on TYPO3 13 against a stub of the EXT:core API, on 14 against the
EXT:install API that 14 still ships as deprecated. The identifier is
unchanged, so an installation that has already marked the wizard done does
not run it again. vault:audit-migrate-hmac was and remains the command-line
path on both majors.
The delete contract matched neither the code nor the rest of the
documentation. VaultServiceInterface::delete() promised a permanent
delete, and the API chapter called it a hard delete, while
SecretRepository::delete() has always soft-deleted: the row keeps its
ciphertext and wrapped DEK, in the database and in every backup. The
interface, the API chapter and the DataHandler comment now say what happens —
gone for every vault operation, no restore, the row retained until removed at
the database level — and point to the decommissioning guide, which now also
notes that a master-key rotation leaves deleted rows under the retired key.
The behaviour itself is unchanged.
README and SECURITY.md named AES-256-GCM as the cipher. The default for
new secrets is XChaCha20-Poly1305, with AES-256-GCM as an opt-in through
encryptionAlgorithm on hosts with hardware AES support; both files and the
security overview now say so, and the README no longer calls XChaCha20-Poly1305
a fallback.
Keyboard focus returns to the control that opened a vault dialog. Reveal, rotate and delete open a modal from a button in the list; closing it dropped focus to the document, so a keyboard or screen-reader user landed at the top of the page and had to traverse the list again to reach the row they were working in.
The coverage shard planner no longer turns a helper directory into a test
run. Every direct subdirectory of Tests/Unit became a shard, including
Fixtures, which holds no test class — failOnEmptyTestSuite then failed
that job and with it the whole evidence bundle for the commit. A directory
becomes a shard only if it contains a test file. The check reads the file
system directly rather than through a pipe: under set -o pipefail a
find … | grep -q answers "no tests here" whenever grep exits first and
find dies on the closed pipe, which would have dropped a directory that
does hold tests, with nothing red to say so.
The SSRF guard refuses a host it could not resolve, instead of handing the
name to the transport unpinned. A failed resolution was read as "nothing is
reachable", on the reasoning that a name nobody can resolve cannot be
connected to either. The two resolvers are not the same resolver:
dns_get_record() speaks DNS and nothing else, while the HTTP transport
resolves through getaddrinfo(), which also reads /etc/hosts, NSS modules
and mDNS. An empty answer therefore meant "no address was checked", and the
request went out for the transport to resolve and connect to whatever came
back. Both halves of the guard now fail closed — the caller-side
isHostAllowed() gate and the ssrf-dns-pin middleware — and an answer
whose records are not parseable addresses counts as no answer. Installing
ext-curl never closed this path; a literal allowed_hosts entry still opens
it deliberately. See ADR-038.
Upgrade note: an endpoint served by /etc/hosts, by an NSS module or by
a container runtime's embedded resolver rather than by DNS is now refused
until its host is listed literally in
$GLOBALS['TYPO3_CONF_VARS']['HTTP']['allowed_hosts'] — the same opt-in the
guard already documents for private addresses. The rejection message names
it, and it is distinct from the message for a disallowed IP range.
No breaking change. create() , sendRequest() and every existing signature behave as before; the cancellable path is additive and opt-in through its ow…
Everything an outbound call does can now be aborted, and everything it refuses is now written down.
VaultHttpClient gains sendCancellable(), behind a separate CancellableHttpClientInterface so nothing existing breaks and consumers can feature-detect it. PSR-18 hands back a response, never a handle, and Guzzle's synchronous send settles its promise before anyone could cancel it — so the send goes async, Proxy::wrapSync picks CurlMultiHandler, whose promise carries a real cancel function, and a ticker drives the loop while a CancellationSignalInterface is polled between passes. create() is untouched; createCancellable() re-composes the stack so the sync and streaming branches survive.
No Guzzle type crosses the interface. The transport detail stays owned here, so the next transport change does not reach into consumers.
The OAuth token round trip is cancellable on the same terms, and audited.
This is what netresearch/t3x-nr-llm#774 was waiting for: a cancelled agent run there ended while the HTTP call it started ran on to its timeout, and going around this package to fix it would have dropped credential injection, the request-time SSRF DNS pin and the audit write. That work can start now.
Three refusals used to escape without an audit row — a scheme that is not allowed, a host outside allowed_hosts, and a credential that could not be obtained. They are exactly the calls an operator goes looking for. Each now writes one http_call row with success = false, status 0, the attempted method, host and path, and a fixed literal naming the gate that refused it. No new audit action: that tuple already means "a refused outbound call" here.
A blocking send that threw something other than a PSR-18 ClientExceptionInterface also left no row — Client::applyOptions() raises outside Client::transfer()'s try/catch, so a bad option set escaped with the credential already injected. The row is now written from a finally, covering the plain and the degraded cancellable path alike.
Two new audit actions describe abandoned calls, each meaning exactly one thing.
The exceptions are unchanged byte for byte — same class, same code, same message, pinned by characterization tests written before the rows existed. A consumer catching VaultException sees no difference.
One DNS lookup per outbound request instead of two: the resolve that pins the address for the SSRF check is the one the request uses.
Tests/Unit/Api/api-surface.txt records the public surface, so a signature cannot change without the diff saying so. The security mutation ratchet now also covers Classes/Http, and the agent documentation is synced and verified in CI rather than by hand.
guzzlehttp/guzzle is now a declared direct dependency (^7.10) — it always was one in practice.
No breaking change. create(), sendRequest() and every existing signature behave as before; the cancellable path is additive and opt-in through its own interface.
All of the above by @CybotTM.
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.16.0.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.16.0.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
A cancellable secure outbound send (#302). VaultHttpClient now also
implements CancellableHttpClientInterface, whose sendCancellable($request, $signal) polls a caller-supplied CancellationSignalInterface and tears the
socket down when it turns true. Until now a consumer whose own work was
cancelled still waited out the timeout — up to about 45 seconds for the caller
that asked for this
(netresearch/t3x-nr-llm#774)
— because PSR-18 returns a response and never a handle, and because Guzzle's
synchronous branch settles its promise before it exists, which makes
cancel() a no-op there.
The new interface is separate from VaultHttpClientInterface and purely
additive, so consumers feature-detect ($client instanceof CancellableHttpClientInterface && $client->supportsCancellation()) instead of
raising a version floor. Nothing existing changes shape.
The invariant is that nothing hands a caller a client that already carries a
vault secret, and VaultHttpClient is the only place nr-vault attaches a
secret to a caller's request. No send a caller can drive puts a vault secret
on the wire without the four protections that are statements in the sending
method rather than middleware — the scheme allowlist, the host allowlist, the
credential injection and the audit write. (nr-vault sends two credentials of
its own elsewhere, on paths that are not a caller's request: the transit
master-key provider's X-Vault-Token header and the OAuth token leg's
client_secret. Both are listed in ADR-037.) Every public method of
VaultHttpClient returns a configured clone, a PSR-7 response or a bool — no
client, no handler, no promise — and sendCancellable() accepts no
per-request option surface (a caller-supplied stream => true or curl array
would silently drop the CURLOPT_RESOLVE DNS pin). Both are asserted by
VaultHttpClientCancellableTest::theCredentialBearingClientExportsNoTransportAndNoPromise().
Building a hardened transport without vault
credentials stays a supported public case: SecureHttpClientFactory::create()
and the new createCancellable() return one, carrying the SSRF middleware and
the DNS pin and nothing else.
A PSR-18 client injected into VaultHttpClient's constructor is never replaced
by a cancellable transport — it may carry that caller's own middleware or
proxy. supportsCancellation() reports false for such an instance and the call
completes blocking on their client. The one exception is withTimeout(), which
has to bake the override into a client and therefore rebuilds one from the
factory; the clone it returns reports supportsCancellation() true
(anInjectedGuzzleClientIsNeverSwappedForACancellableTransport(),
withTimeoutRebuildsACallerSuppliedClientAndTurnsCancellationOn()). A client
obtained from VaultServiceInterface::http() supports cancellation wherever
curl_multi_* is available, and degrades to a blocking send where it is not —
feature-detect with supportsCancellation() rather than assuming either.
allow_redirects => false is re-pinned per request on the async path. Guzzle
pins it for every PSR-18 send but sets nothing on an async one, so on an
install that configured
$GLOBALS['TYPO3_CONF_VARS']['HTTP']['allow_redirects'] this path alone would
have started following redirects — past a DNS pin computed for the original
host.
The timeout stays authoritative: a transport this client builds carries the
same deadline as its blocking client, so cancellation is an early exit and
never an extension
(VaultHttpClientCancellableTest::theTransportTheClientResolvesForItselfCarriesTheRememberedTimeout()).
Two new audit actions for abandoned calls, each meaning exactly one thing.
Every call leaves exactly one row from this client — sendCancellable() and
sendRequest() alike — so the log is complete with respect to calls and not
merely with respect to egress: a call that was already cancelled when it began
gets a row too. ADR-037 maps every outcome to the test that asserts its row.
http_call_cancelled (badge: warning) is written only when the
cancellation signal stopped an in-flight request, i.e. after the credential was
retrieved, injected and handed to the transport. Because it means nothing else,
"which calls were abandoned after their credential went out?" is a query on one
action value, with no message parsing
(theTwoCancellationOutcomesAreToldApartByTheirAction()). It is a separate action rather than a
failed http_call because status 0 there already means both "connection
refused" and "SSRF middleware rejected".
http_call_cancelled_before_send (badge: info) is written when the signal
was already set on entry: no secret was read and nothing egressed. Its own
action rather than a distinguishing message, so an auditor can exclude those
rows by query.
Everything that failed rather than was cancelled stays http_call with
success = false — a transport that could not be built, a transport error, the
defensive wall-clock bound, a settlement that is not an HTTP response, and a
throw from the caller's signal or the ticker. Nobody asked for those; filing them under the cancellation
action would put a second meaning back on it. The fixed-literal error_message
rendered under the badge identifies the situation within an action.
The audit write for a cancellable transfer happens in a finally that opens on
the first statement after the credential was injected: Guzzle's option handling
inside sendAsync(), the caller's signal and the ticker can all throw, and
none of them may be the one outbound call that leaves no trace.
The OAuth token round trip is audited and cancellable (#303). The
outbound POST that carries the client_secret used to leave no trace:
VaultHttpClient built its OAuthTokenManager without the audit service,
and the blocking token send ran before the cancellable transfer, out of the
signal's reach. Now every attempted round trip — completed, refused by the
allowed_hosts gate, failed in transport, or cancelled — writes exactly one
row under the new oauth_token_request action, carrying the endpoint, the
real HTTP status and a fixed literal (or redacted upstream message) naming
the outcome. The row is crash-safe like the manager's other audit writes: an
audit outage is reported loudly but never costs the caller a token the OAuth
server already issued.
On sendCancellable(), the signal now reaches the token leg exactly when
the call's own transfer runs cancellable: an already-cancelled call reads no
credential from the vault, a cancellation before the send never serialises
the client_secret, and an in-flight token POST is torn down through a
cancellable transport with the same hardening as the blocking client. On the
degraded blocking path the token leg blocks like everything else, so the two
legs never disagree on abortability. getAccessToken() gains an optional
trailing $cancellationSignal parameter; every existing call keeps
compiling.
An api-surface snapshot test (#306). Tests/Unit/Api/api-surface.txt
freezes the rendered public surface — every interface, enum (backing
values included) and exception class under Classes/, the Domain/Dto
value objects, plus every own-namespace type their signatures mention,
constructors included. A change to any frozen signature now has to be an
explicit commit with a visible snapshot diff rather than a side effect;
the failure message classifies the diff as additive (regenerate) or
breaking (a decision, per AGENTS.md's "Ask First" rule for interface
signatures). Ported from nr-llm, which has carried the same guard since
ADR-127, with one deliberate divergence — backed-enum values are rendered,
proposed upstream as t3x-nr-llm#815.
The security mutation ratchet now also covers Classes/Http (#307). The
gate (infection-security.json5, enforced by the Security gates workflow)
previously measured only Classes/Crypto, Classes/Security and
Classes/Audit, so it said nothing about the outbound credential path. The
release-evidence manifest reports the four-directory scope accordingly. The
floor was re-baselined 86 → 82 from the first four-directory CI measurement
(MSI 83.84 %); raising it back above 86 by hardening the Http suites is
tracked in #328.
Agent documentation synced and put under CI verification (#325). Root
AGENTS.md shrank from 296 to 144 lines: the Key-Interfaces cheat-sheet, the
vault:* CLI list, the component map and the phpat dependency rules now live
in the new agent-facing docs/ARCHITECTURE.md; the audit-log invariants and
the backend-submodule completeness recipe moved into Classes/AGENTS.md.
Drifted claims were corrected (composer ci scope, make ci scope in the
Classes/ checklist, stale ddev exec phpunit instructions in Tests/ and
.ddev/ that contradicted the runTests.sh mandate), and
Documentation/CLAUDE.md was added as a regular file (the docs renderer
rejects symlinks). A new harness-verify workflow runs
Build/Scripts/verify-harness.sh on every PR so this class of drift fails CI.
One DNS lookup per outbound request instead of two (#304). The
caller-side isHostAllowed() gate and the ssrf-dns-pin middleware each
ran their own dns_get_record(); a short-lived memo (5 s, per host) inside
SecureHttpClientFactory now lets the middleware reuse the gate's answer.
The sharing is bounded by issue #304's security constraint: it only applies
where every memoised IP is still range-checked and then pinned via
CURLOPT_RESOLVE, so a memoised answer can never admit an address a fresh
one would have rejected — a rebind inside the TTL just means curl connects
to the address that was actually vetted. Where no pin takes effect — no
ext-curl, or a stream => true transfer, which Guzzle routes to the
StreamHandler that ignores the curl options — the middleware's own resolve
IS the rebind defence and keeps resolving fresh, decided per hop. (The
isHostAllowed() gate itself may share answers within the TTL in every
mode; its check was always advisory relative to connect time, and the
middleware re-validates.) Failed resolutions are never memoised; every
failure-path behaviour is unchanged.
guzzlehttp/guzzle is now a declared direct dependency (^7.10).
Production code already imported GuzzleHttp\Client, HandlerStack and
RequestException while the manifest named only the PSR interfaces and relied
on typo3/cms-core to pull Guzzle in transitively. The cancellable transport
reaches deeper still — CurlMultiHandler, Proxy, StreamHandler, promise
cancel semantics — and a Guzzle major arriving through a third path would
break it with no warning in our own manifest.
A refused scheme, a host outside allowed_hosts, and a credential that
could not be obtained left no audit row. All three are thrown by
VaultHttpClient before the send, and all three used to escape without a
trace — on sendRequest() as much as on the new cancellable path. They are
exactly the calls an operator goes looking for: somebody tried file://, or a
host nobody approved. Each now writes one http_call row with
success = false, status 0, the attempted method/host/path, and a fixed
literal saying which gate refused it (Request refused before any secret was read: …, Credential injection failed; nothing was sent: …). No new audit
action: that tuple already means "a refused outbound call" here — the SSRF
middleware rejection lands in it too.
The exceptions are unchanged, byte for byte: same class, same code, same
message, pinned by characterization tests written before the rows existed. A
consumer that catches VaultException sees no difference.
A blocking send that threw something other than a PSR-18
ClientExceptionInterface left no audit row. Client::applyOptions() raises
InvalidArgumentException outside Client::transfer()'s try/catch on the
synchronous path as well, so a bad option set escaped VaultHttpClient's
catch — credential already injected, nothing in the log. The send-and-audit
helper now writes its row from a finally, which covers plain sendRequest()
and the degraded branch of sendCancellable() alike, under http_call with
the fixed literal Blocking send aborted by an unexpected error after the credential was injected: …. Success and transport-failure rows are unchanged.
Let vault:store write as a configured provisioning actor
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.15.0.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.15.0.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
One feature, aimed at a gap that only shows up in unattended deployments: there
was no way to write a secret from a pipeline without switching on
allowCliAccess, which grants the operation to every process holding a shell in
that container and attributes the write to nobody.
A named actor for unattended writes (#298). vault:store --as-provisioner
performs the store inside TechnicalActorContext::runAs() as the backend user
named by the new provisioningBeUserUid option. The actor needs no admin
flag — a group carrying tx_nrvault:secret.create is enough, because a
technical actor's grants are read from its groups' custom permission options.
The grant is therefore one operation on one identity, and every write it makes
is attributable in the audit log.
The UID comes from configuration and never from the command line. A flag that accepted a UID as an argument would be a general impersonation primitive, strictly worse than the switch it replaces.
Fail-closed at both ends: a non-numeric, negative or absent value reads as no provisioning actor rather than uid 0, which names the unauthenticated CLI placeholder the option exists to avoid; and the flag without a configured actor is an error, not a silent fallback to the unattributed write the caller explicitly asked not to make.
ext_conf_template.txt already recommended "prefer a named technical actor
(TechnicalActorContext::runAs())" over allowCliAccess. Until now no command
could enter such a scope, so the advice had nothing behind it.
runAs() captured the plaintext by value, so
sodium_memzero() separated the copy-on-write pair instead of wiping both and
the secret survived in memory in the copy the command believed it had cleared.
Caught in review before release, so no shipped version is affected.step-security/harden-runner to v2.20.1,
actions/attest-build-provenance to v4.2.2.Fifty-eight pull requests since v0.13.0, most of them one hardening programme carried out in four rounds of adversarial review. Several changes are br
Fifty-eight pull requests since v0.13.0, most of them one hardening programme carried out in four rounds of adversarial review. Several changes are breaking, and several close paths that produced successful-looking audit entries for operations that were never authorised — in an extension whose promise is auditability, that is the part to read first. Read the 0.14.0 changelog end to end before upgrading; its Migration section lists everything an operator has to do by hand.
Ten grantable operation permissions replace the coarse admin-only model: secret.use, secret.reveal, secret.create, secret.rotate, secret.delete, secret.manage_policy, audit.view, audit.export, master_key.rotate and vault.configure. They are granted per backend group and compose with — never replace — the per-secret owner/group ACL tiers; both must hold. The operationally important split is use ≠ reveal: an application or technical actor can consume a credential no human is allowed to look at, and audit viewing is separated from secret access entirely.
The admin bypass itself can now be switched off. disableAdminOverride removes it from both layers and can be pinned out of admin reach in config/system/additional.php. The way back in is vault:break-glass --activate --reason "…", a time-boxed window (1–60 minutes, default 15) whose audit row is written before the power is granted, so an open window without evidence is impossible. PSR-14 events fire for SIEM consumption and the backend modules carry a danger banner while a window is open.
securityProfile (standard | hardened) is policy, not a documentation label. Under hardened, masterKeyProvider = typo3 is a configuration error — vault secrets must not be protected by the same key TYPO3 uses for everything else — and provider selection stops auto-detecting, so a missing provider fails loudly instead of silently degrading down the old typo3 → env → file chain. An unknown profile value throws rather than falling back, because a typo must never weaken the effective policy.
New in this release: a HashiCorp Vault Transit master key provider, and vault:doctor, a readiness command with stable exit codes (0 clean, 1 warnings, 2 critical) that an operator or a pipeline can gate on. vault:doctor --profile=hardened reports what would fail before you change any configuration; --active-probes is the only check that proves audit records actually arrive rather than that a URL parses.
The audit chain tip is now anchored both inside the database and outside it, so deleting the audit table no longer leaves a chain that verifies as valid. A truncated log verifies as INVALID and blocks vault:rotate-master-key and both HMAC re-seal paths, which would otherwise launder it. Audit sinks gained active verification and per-sink delivery state. vault:audit --reset-anchor is the deliberate way to re-arm the anchor after a wipe you performed on purpose, and it writes the reset into the chain so it cannot be done invisibly.
VaultServiceInterface::clearCache() and the cacheEnabled setting. Every read is now one SELECT, one decrypt and one audit row — which is the point.VaultServiceInterface, SecretRepositoryInterface, VaultAdapterInterface and VaultDoctorServiceInterface gained members. Third-party implementations need updating; the two new parameters are optional, so only the new methods are a hard break.undelete is gone for tx_nrvault_secret, for everyone including administrators. Restoring a soft-deleted secret is now a database operation, where the change is visible as what it is. Update any runbook that promised the delete was reversible.%vault()% resolution only serves published identifiers. Every documented TypoScript and site-configuration form keeps working; an identifier used only in a Fluid file, a userFunc or a DataProcessor must be published once per site via plugin.tx_nrvault.frontendResolvableIdentifiers. Scheduled render jobs are covered by the same allow-set — check them before upgrading.cliAllowedOperations, default secret.use,secret.create,secret.rotate). Anything that reveals, deletes, exports, rotates the master key or touches the audit log needs allowCliAccess = 1 and the operation added to that list. Prefer a named technical actor via TechnicalActorContext::runAs() so the audit trail names an identity rather than an unattributable shell.secret.use before you upgrade, not after. Non-admin editors working with vault-backed FormEngine or FlexForm fields need it, and that is the one that bites quietly.reseal() skipped its anti-truncation guard on the rotation path, because it authenticated the stored anchor under the key it was about to sign with; rotation is the one path that passes a different key. Reported by @CybotTM in #283, fixed by @CybotTM in #284.store() carried version and crdate forward but reset last_rotated_at, read_count and last_read_at on every update, so a rotated secret read as never rotated — fixed by @CybotTM in #281.Every issue in this release was reported by @CybotTM and every pull request authored by @CybotTM; #283 and #286 are the two that were filed as issues first.
Full Changelog: v0.13.0...v0.14.0
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.14.0.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.14.0.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
Fifty-eight merged pull requests since 0.13.0, most of them a hardening programme carried out in four rounds of adversarial review: a technically enforced security profile, ten grantable operation permissions in place of the admin-only model, a tamper-evidence anchor inside and outside the database, and a readiness command an operator or a pipeline can gate on. Several changes are breaking, and several close paths that produced successful-looking audit entries for operations that were never authorised — in an extension whose promise is auditability, that is the part to read first. Start with Changed and end with Migration, which collects everything you actually have to do.
securityProfile (standard | hardened,
default standard) is a technically enforced policy, not a documentation
label. Under hardened, masterKeyProvider = typo3 is a configuration error
— vault secrets must not be protected by the same key TYPO3 uses for
everything else — and provider selection stops auto-detecting: the configured
provider is returned even when it is unavailable, so getMasterKey() fails
loudly instead of silently degrading down the old typo3 → env → file chain.
An unknown profile value throws rather than falling back to standard,
because a typo must never be able to weaken the effective policy.
VaultHealthService and vault:rotate-master-key resolve through the same
factory, so a misconfiguration surfaces in the system status and blocks
rotation instead of being discovered at decryption time.secret.use, secret.reveal, secret.create, secret.rotate,
secret.delete, secret.manage_policy, audit.view, audit.export,
master_key.rotate, vault.configure. They are carried as TYPO3 custom
permission options (be_groups.custom_options, namespace tx_nrvault) and
granted per group in the Backend Users module, and they compose with — never
replace — the per-secret owner/group ACL tiers: both must hold. The split that
matters operationally is use ≠ reveal: secret.use gates every
interactive plaintext read, while displaying a secret additionally needs
secret.reveal, so an application or a technical actor can consume a
credential no human is allowed to look at. Audit viewing is separated from
secret access entirely (audit.view / audit.export), and the "admins may do
anything" override lives in exactly one seam so it can be switched off as a
whole (see break-glass, below).disableAdminOverride (hardened profile only;
inert under standard as a lockout guard) removes the admin and
system-maintainer bypass from both layers — the operation permissions and
the per-secret read/write/delete tiers — and can be pinned out of admin reach
in config/system/additional.php via
$GLOBALS['TYPO3_CONF_VARS']['SYS']['nrVault']['disableAdminOverride'].
vault:break-glass --activate --reason "…" [--minutes N] (1–60, default 15)
opens a time-boxed window; the audit row is written before the power is
granted, so an open window without evidence is impossible, and a logged failed
opening is harmless. While a window is open the vault backend modules carry a
danger banner, --status is machine-readable, and PSR-14
BreakGlassActivatedEvent / BreakGlassDeactivatedEvent fire for SIEM
pickup. A runAs() technical actor may not open one — break-glass is not an
authentication boundary.vault:v1:… ciphertext is
stored locally (0600, atomic write); every unwrap is a live, centrally
audited Vault call, so revoking the token or its policy locks the vault out
immediately and a stolen database plus webroot is useless on its own. Token
auth only — approle and kubernetes are rejected rather than silently
downgraded. New settings hashicorp.transitMount, hashicorp.transitKeyName,
hashicorp.transitWrappedKeyPath, hashicorp.tokenEnvVar. The trust model is
documented honestly: Transit protects custody, rotation and central
auditability; it does not stop a fully compromised PHP process from calling
decrypt with the token it legitimately holds.AuditSinkInterface plus three sinks — syslog (RFC 5424 structured data,
control characters stripped against log forging), an append-only NDJSON file
(0600, LOCK_EX, refuses any path inside the public web root, resolved
against the nearest existing ancestor so .. and symlinks cannot bypass it)
and an SSRF-guarded HTTP webhook. Fan-out happens after commit and after the
advisory lock is released, so a slow sink can never serialize vault operations
behind the lock and a sink failure never rolls back the audited operation.
vault:audit-anchor publishes {sequence, chainTip, timestamp, hmacEpoch}
through every enabled sink and the reader takes the highest anchored
sequence, so appending a low anchor cannot weaken the baseline;
vault:audit-verify then checks the chain and compares it against that
anchor, reporting a table that was truncated and re-seeded with a
valid-but-different chain as TABLE_RESET. Machine-readable reason codes
(HASH_MISMATCH, UID_GAP, TABLE_RESET, EPOCH_DOWNGRADE, SINK_FAILURE,
NO_EXTERNAL_SINK, BREAK_GLASS), scheduler tasks for both commands, and a
PSR-14 AuditIntegrityAlertEvent forwarded through the sinks. Seven new
settings under "Audit Sinks", all off by default.vault:doctor (#244) — one readiness check across the whole security
posture, with exit codes a pipeline can gate on: 0 clean, 1 warnings,
2 any critical. One class per control (provider explicitness, availability
and key-file permissions; profile consistency; break-glass window; audit
reads, retention, chain, external sink and anchor; CLI exposure; secret expiry
and rotation hygiene; production context and HTTPS; version sanity), each
declaring which profiles it applies to, each producing a typed Finding with
id, severity, risk, remediation and a documentation link. A crashing check is
contained as a check.crashed critical finding rather than taking the run
down with it. --profile=hardened asks "would this installation pass as
hardened?" without changing any configuration, which is what makes it usable
as a deployment gate. The backend overview shows a profile badge and "N/M
controls passed" to any vault user; the detailed findings are gated behind
vault.configure.vault:doctor always saw zero failures and reported a collector that had been
unreachable for days as healthy. Per-sink delivery state (last success, last
failure, consecutive failures, lifetime failures, last error) is now persisted
in sys_registry, written fail-safe on every dispatch outcome and throttled
to one healthy write per minute per sink, so the bookkeeping can never fail or
slow the audited operation. New audit.sink_state.<sink> findings grade
consecutive failures and a last success older than
auditSinkStaleDeliveryHours (new setting, default 24) as a warning under
standard and critical under hardened. vault:doctor --active-probes
goes further and pushes the current chain tip through every enabled sink end
to end — a refused probe is critical in both profiles. Probes never run
implicitly: not from the passive checks, not from the backend status panel.
The anchor is re-publishable evidence by design, so a probe pollutes nothing
and even refreshes the external anchor.audit.hmac_epoch (#260, graded
further in #268 and #277): critical at epoch 0 — one setting that
simultaneously drops row hashes to keyless SHA-256, makes the chain-level
downgrade guard vacuous and disables the in-DB anchor, silently overriding
auditAnchorRequired; warning at epochs 1–2, where 13 and 5 columns
respectively sit outside the signed payload (at epoch 1 success itself is
forgeable, so a recorded denial can be flipped into a recorded grant without
touching a signed byte); pass only at ≥ 3. The check now reads the stored
minimum epoch from the oldest row rather than grading the configuration alone,
and reports it as details.storedMinEpoch for CI. audit.db_anchor (#260)
loads the sys_registry anchor directly, which no check did before at any
epoch. cli.frontend_placeholder_legacy (#268) reports the
frontendPlaceholderLegacyCli opt-in — emitted from both return paths of the
CLI check, because the obvious placement would have skipped it on exactly the
default installations where the bypass is fully live. And
cli.allowed_operations (#277) stops calling secret.manage_policy and
audit.view harmless: the first governs the permissions themselves, so a
shell can widen its own per-secret reach; the second maps out the credential
topology and is where that shell's own activity is recorded.VaultServiceInterface::setEnabled(string $identifier, bool $enabled, string $reason = ''): void — absolute, not a toggle, so two concurrent disables
converge instead of cancelling out and leaving two audit entries claiming
opposite outcomes. It resolves the secret through a disabled-visible lookup,
asserts canWrite() and secret.manage_policy, no-ops when the state
already matches (the gates run first, so a refused no-op is still audited),
and reverts on AuditWriteException exactly as the other compensating paths
do, including the CRITICAL escalation when the revert itself fails. It is
audited as metadata_update, matching what the FormEngine path already writes
for the same column, so "who disabled this secret" has one answer regardless
of the write path. list() and the repository filters gained
$includeDisabled (default false) so the management surfaces can see what
the read paths no longer return.vault:audit --verify reports the anchor state on a Tip anchor: line, and
the backend verification view shows it too. vault:audit-verify additionally
reports the Stored HMAC epochs distribution in text and JSON (#277) — the
count is free, since verifyHashChain() already walks every row.vault:audit --reset-anchor clears the anchor after a wipe or purge you
performed deliberately, writes the reset into the chain so it cannot be done
invisibly, and re-arms the anchor on that entry. New audit action
audit_anchor_reset.auditAnchorRequired extension setting (default off). A missing anchor
becomes an error, and ordinary audit writes stop arming an anchor that is not
there — whatever the log currently contains, an emptied one included — so
deleting the anchor and truncating or wiping the log can no longer be
laundered back to a valid verdict by ordinary traffic. Enable it after the
first audit write following the upgrade; while it is on, vault:audit --reset-anchor is the only way to arm the anchor.Documentation/Security/ gains a threat model, the profile comparison, the
trust boundaries, the cryptography chapter, what the audit chain does and does
not prove, and a prominent KnownLimitations page.
Documentation/Operations/ covers hardened deployment, key custody, backup
and restore, key rotation, monitoring and alerting, incident response and
decommissioning. Documentation/Auditor/ states the target of evaluation,
maps controls to BSI IT-Grundschutz and OWASP ASVS with a declared-gaps
table so an assessment credits no absent control, and gives reproducible
evidence and verification procedures (the staging-only ones marked as such).
The language is deliberately unmarketed throughout — tamper-evident, not
tamper-proof; minimized exposure, not secure deletion.processDatamap_preProcessFieldArray() (DataHandler writes them
during checkValue(), before any audit hook runs, and the row's
allowed_groups column holds only a relation count, so restoring the column
restores nothing), and why the create path is asymmetric (for a NEW record
the MM writes are deferred past the hook, so a reverted creation deletes the
row before its relations exist and leaves orphans to purge). It also states
where the guarantee stops rather than leaving the boundary implied.Build/Scripts/collect-evidence.php
assembles whatever exists at release time — test results, line and
security-directory coverage, whole-codebase and security-scoped mutation
summaries, PHPStan level, composer audit, vault:doctor --format=json —
into a flat, stable evidence-manifest.json plus a human-readable
EVIDENCE.md. An absent producer is absent and exit 0; only a
present-but-malformed artifact is an error.
.github/workflows/release-evidence.yml runs it on a tag and publishes the
bundle as a run artifact with a build-provenance attestation over the tarball,
verifiable with gh attestation verify. CONTRIBUTING.md now requires
two-person review for Classes/Crypto|Security|Audit (the author cannot
approve; one approver must be a code owner) plus a threat-model delta, and
SECURITY.md carries a 7-day Critical/High patch SLA, down from 30.Classes/Crypto|Security|Audit and 80% by default, with
the ignore list mirroring the PHPUnit excludes so the reported number is the
one the suite actually measures (#242, #258). A security-scoped mutation gate
ships as infection-security.json5 with a raise-only ratchet, now at MSI
86 after a pass that killed 273 escaped mutants by pinning previously
unasserted semantics across the audit, crypto and security trees — test-only,
no production code touched (#242, #257). The gate treats its own inputs as
security-critical, so a pull request lowering the ratchet is measured rather
than waved through. Alongside: PHPStan now analyses the whole Tests tree
instead of Tests/Architecture only (#233), the CLI documentation guard
checks each command's option lists against the real addOption() calls
and not merely the shell examples (#273), sixteen previously untested classes
reached full line coverage (#258), and the envelope fuzz probe spans two full
base64 block periods and reports every observed length when it fails (#256).The request-scoped plaintext cache is gone, and
VaultServiceInterface::clearCache() with it (breaking) (#250). See
Security for what the cache did; what changes for integrators is that the
method no longer exists — there is nothing left to clear — and the
cacheEnabled extension setting,
ExtensionConfiguration(Interface)::isCacheEnabled() and the
ext_conf_template.txt entry are removed too. The saved work was a single-row
SELECT plus one decrypt; an actor-keyed cache was considered and rejected,
because permission state, break-glass windows and expiry all change within a
request and a cache that has to track them is a second authorization
implementation.
allowCliAccess no longer grants every operation (breaking) (#254). With
the switch on — which deployment automation genuinely requires — a shell on
the host implicitly held secret.reveal, secret.delete, audit.export,
master_key.rotate and vault.configure, because isGranted() returned the
trust switch regardless of which permission was asked for. The new
cliAllowedOperations setting defaults to
secret.use,secret.create,secret.rotate, and both CLI branches now require
the trust switch and the allowlist. Everything else needs an explicit
opt-in, so vault:retrieve, vault:delete, the scheduled orphan cleanup,
vault:audit --export and vault:rotate-master-key stop working on
installations that relied on the blanket grant. Under the hardened profile,
unattributed CLI access is now a critical doctor finding rather than a
warning.
The frontend placeholder allow-set applies on the CLI too (breaking)
(#262). ADR-035 scoped %vault()% resolution to published identifiers in web
requests, but the CLI branch returned early with a blanket true. That looked
defensible while plain unauthenticated CLI still failed closed on the
allowCliAccess = 0 default — except scheduler:run authenticates the
_cli_ admin user, so the admin bypass grants vault reads regardless of
that switch. For editor-authored content rendered by a scheduled job — a
newsletter, a static export, a search indexer — the allow-set was therefore
the only remaining gate, and it was the one being skipped. The CLI is now
strict like everything else. frontendPlaceholderLegacyCli (default 0)
restores the previous behaviour byte for byte; it is CLI-scoped so it cannot
weaken a web request, read per call rather than memoised, fails closed on
unreadable configuration, and honours the $TYPO3_CONF_VARS pin.
Installations whose scheduler jobs resolve unpublished frontend_accessible
identifiers must publish those identifiers or set the flag.
Every audit CLI entry point now asserts an operation permission (breaking)
(#274). vault:audit, vault:audit-verify and vault:audit-anchor gated
nothing at all, while the same capabilities were gated in the backend module
all along: anyone who could invoke them could read the audit log — who touched
which secret when, which maps out the credential topology — carry an unchained
copy of it off with --export, clear the tamper-evidence tip anchor, or
re-attest a truncated chain to the external sinks. The permission now follows
the operation's effect, so the same operation answers to the same permission
through every entry point:
| Operation | Permission | Entry points |
|---|---|---|
| Read audit entries | audit.view |
audit module, vault:audit |
| Verify the chain | audit.view |
audit module, vault:audit --verify, vault:audit-verify, AuditVerifyTask |
| Export to a file | audit.export |
audit module, vault:audit --export |
| Publish the chain tip | vault.configure |
vault:audit-anchor, AuditAnchorTask |
| Reset the tip anchor | vault.configure |
vault:audit --reset-anchor |
Verification is a read of the chain — it recomputes and compares, it mutates
nothing — so it shares audit.view with the listing rather than taking the
administrative permission. Anchoring and resetting the anchor do mutate tamper
evidence: an actor who truncates the log and then anchors makes the external
sink attest the truncated chain, which is the laundering the anchor exists to
prevent. A refusal exits 1 before any query, file write, chain read or anchor
change happens, and writes no access_denied entry — the same shape as every
other operation-permission gate (vault:retrieve, vault:rotate-master-key,
the backend modules), and for --verify and --reset-anchor the deciding
argument is recursion: a denial entry would append a row and advance the tip
anchor, mutating the very state the operator is about to inspect. In
--format=json a refused vault:audit-verify reports valid: false, so a
monitor never reads it as a clean chain. OrphanCleanupTask is deliberately
not gated at task level: its effect already answers to secret.delete one
layer deeper.
undelete, copy and move are refused for tx_nrvault_secret
(breaking) (#276). Each is marked handled so core never reaches its cmdmap
branch, writes an access_denied audit entry and emits a DataHandler error
naming the reason. localize, copyToLanguage, inlineLocalizeSynchronize,
discard and version are deliberately left alone — each was verified inert
for this schema, and a gate with nothing behind it is noise. Administrators
are refused too, and that is a product rule rather than a permission tier:
an exemption would make "the vault cannot restore it" mean "unless an
administrator says otherwise", and would write a restore into the HMAC chain
that the vault never performed. An operator who must resurrect a row still has
the database, where the change is visible as what it is. The user-facing
wording follows: the confirm dialogs, the TCA field clear and vault:delete
now all say "The vault cannot restore it. The encrypted record is retained in
the database until it is removed there.", replacing a documentation passage
that called the soft delete "auditable and reversible".
The ext_emconf.php TYPO3 constraint is capped at 14.3.99 (breaking for
anyone installing via the Extension Manager against a future 14.x) (#271).
The previous 13.4.0-14.99.99 claimed compatibility with 14.4 through 14.99,
which do not exist as supported releases — v14.3 is the LTS. This does not
reopen the 0.7.0 decision to keep a coarse range: that reasoning was about the
gap a single continuous range cannot express (the unsupported 14.0–14.2
sprint releases, still spanned), not about the ceiling. composer.json
remains authoritative for a Composer-based installation.
Six interfaces gained members, which is breaking for third-party implementations (#259, #263, #278, #280, #281). Each addition exists because a caller needed a narrower or a wider operation than the interface could express; none changes an existing signature's meaning, and the two optional parameters default to today's behaviour so existing callers and test doubles bind unchanged.
| Interface | Addition | Why |
|---|---|---|
VaultServiceInterface |
assertDeletable(string $identifier): void |
runs delete()'s gates without deleting, so a record spanning several vault fields fails closed before the first irreversible deletion |
VaultServiceInterface |
setEnabled(string $identifier, bool $enabled, string $reason = ''): void |
the single audited write path for a secret's availability |
VaultServiceInterface |
list(?string $pattern = null, bool $includeDisabled = false): array |
lets the management surfaces see secrets the read paths no longer return |
SecretRepositoryInterface |
findByIdentifierIncludingDisabled(), findByUidIncludingDisabled() |
lift HiddenRestriction by name for administrative lookups; removeAll() was rejected because it would also discard DeletedRestriction and resurrect soft-deleted rows |
SecretRepositoryInterface |
setHidden(int $uid, bool $hidden): void, setMetadata(int $uid, array $metadata): void |
column-scoped writes, so a metadata or availability change stops rewriting the whole row |
SecretRepositoryInterface, VaultAdapterInterface |
save() and store() gain bool $persistGroupRelations = true |
lets the FormEngine completion path keep MM rows and their count columns consistent instead of zeroing the tiers |
VaultDoctorServiceInterface |
run() gains bool $activeProbes = false |
required by --active-probes |
The backend modules and AJAX routes moved from admin to user (#238),
and every controller action asserts its own operation instead: the overview
filters its submodule cards by permission and the templates render only the
actions the user holds. Non-admin editors working with vault-backed FormEngine
or FlexForm fields therefore need secret.use, which they did not need
before. vault:rotate-master-key now needs allowCliAccess = 1 like every
other secret command — the _cli_ user is never logged in, so group grants
cannot apply to it — and, since #254, master_key.rotate in
cliAllowedOperations as well.
Technical actors resolve their grants from their backend groups (#251).
They were previously hard-coded to an implicit secret.use, which central
enforcement would have turned into "no runAs() worker can ever mutate
anything". The other operation permissions are now read straight from the
tx_nrvault:<permission> custom options on the actor's be_groups rows —
never through BackendUserAuthentication::check(), which core short-circuits
to true for admins — and fail closed: no groups, no grant. secret.use
stays implicit.
A truncated audit log now verifies as INVALID. This also blocks
vault:rotate-master-key and both HMAC re-seal paths, which already refuse to
run on any other chain error — re-sealing a truncated chain would launder it.
Use vault:audit --reset-anchor for a truncation you performed on purpose.
Installations upgrade into a populated chain with no anchor row; that is a
warning, not an error, and the anchor arms itself on the next audit write.
Frontend %vault()% resolution is restricted to frontend requests and to
any web request whose type cannot be established — eID among them, where
$GLOBALS['TYPO3_REQUEST'] does not exist. CLI and backend requests were
unchanged by ADR-035 and are now covered separately (see the CLI entry above);
a backend request is recognised by the request the content object renderer
carries, never by what an earlier request left in the superglobal, and a
renderer built without a request of its own is restricted wherever it runs.
Log volume on the rejection path is bounded by a latch that is per request and
only engages in a frontend or unknown web context: 100 injected placeholders
naming a withheld secret used to produce 100 warnings and 100 AccessDenied
audit rows, and now produce at most one record and no rows. The latch cannot
carry into the next request and never engages on the CLI, so a long-running
scheduler:run or Messenger consumer keeps every warning it emitted.
Detection trade-off. Because a rejected identifier is refused before the
vault is touched, it no longer produces the AccessDenied audit row it used
to. Outside Development a rejection is written nowhere, so probing for a
site's published identifiers leaves no trace; the signal is the literal
%vault(...)% surviving in the output. This is deliberate — any per-rejection
record is a write an anonymous visitor can drive — and is recorded as a
residual in ADR-035.
The documentation was audited against the shipped code and roughly seventy
drift items corrected (#266, #267, #246, #264). The corrections worth naming
are the ones that ran the wrong way: the auditor documentation claimed a
secret-scanning control does not exist when it runs on every pull request,
while both AGENTS.md files claimed a gitleaks scan that did not run at all —
a false control claim in a secrets extension changes behaviour, because a
contributor adding fixtures relaxes their own care trusting a scan that never
happens. Verification procedure 6c expected a TRUNCATE to leave a valid
chain, which the ADR-034 anchor now correctly reports as invalid. ADR-034
prescribed the one entry_namespace value the code deliberately avoids,
ADR-005's code sample taught the inlined isAdmin() pattern the codebase
forbids, vault:store and vault:retrieve documented options that do not
exist, and TcaIntegration.rst's resolver examples were fatal errors. Three
passages claimed multi-field copy and delete are atomic, where the real
guarantee is preflight plus best-effort compensation with three named
residuals; the code was honest about this in its own log messages, only the
prose said "never".
Dependencies and CI, grouped: actions/upload-artifact to v7 (#247), the
zizmor policy first added locally and then removed once the shared reusable
started serving it centrally (#253, #265), the labeler moved into its own
pull_request_target workflow so fork pull requests stop failing with
Resource not accessible by integration (#275), and the unit tests moved off
expectExceptionMessage(), which PHPUnit 13.2 deprecated — via a trait
routing to expectExceptionMessageMatches() with a preg_quoted needle,
because the official replacement landed in 13.2.0 and four of eight matrix
cells resolve PHPUnit below that (#279).
reseal() skipped its anti-truncation guard on the master-key rotation
path (#283). The guard refuses to re-sign a shortened chain, but it can
only check a stored anchor it has authenticated — and it authenticated under
the key it was about to sign with. Rotation is the one path that passes a
different key, so the old-key MAC never verified, the guard was skipped, and
reseal() minted a fresh anchor over whatever the tip was at that moment.
The command's pre-flight chain verification kept this theoretical (the rows
would have to disappear inside the rotate transaction), which is why it was
tracked as a follow-up rather than an advisory. reseal() now falls back to
the provider's current key to authenticate the stored anchor, putting the
rotation path behind the same guard as the two migration paths.store() carried
version, crdate and cruser_id forward from the existing record but not
last_rotated_at, read_count or last_read_at, so all three fell back to
their 0 constructor defaults and were written on every update — from the
module's edit form, vault:store, vault:migrate-field, the FormEngine
completion path and any programmatic call alike. rotate() was never
affected. This is not cosmetic: those are the columns the module's Reads and
Last read display, that rotation-age reporting consults, that the
orphan-cleanup heuristics act on, and that an audit reads. The whole suite was
green while the bug was live, which is itself part of the finding.updateMetadata() wrote the whole row (#281), carrying the same
concurrency exposure setEnabled() was fixed for, while its own docblock
promised a write "without changing the secret value". It is narrowed rather
than removed — there is no production caller, but it is declared on
VaultAdapterInterface, the documented extension point for third-party
adapters (ADR-007), so deleting it would be a public-API break for a defect
that is fixable in place. The merge stays in the adapter; the new
setMetadata() primitive writes the metadata column and tstamp and nothing
else.isUpdateAuthorized() and enforcePrivilegedColumnPolicy() both treated
"record not found" as "allowed". A null lookup is not hypothetical: core reads
its datamap target with the delete clause off and skips only on an empty
record, so a soft-deleted tombstone has a pid, is processed by core, and was
waved straight through by the vault. Both now refuse. They report differently,
because only one of them has an identifier — a tombstone gets an
access_denied entry under its own identifier plus a DataHandler error, an
absent record gets the error alone, so nothing enters the tamper-evident chain
anonymously. enforcePrivilegedColumnPolicy() returns a bool and the caller
nulls $fieldArray rather than dropping the privileged columns: the read that
failed is the same read that would supply the stored values to compare
against, so dropping columns is not fail-closed there.editAction threw
SecretNotFoundException, and a fresh store() classified as a creation and
collided with the unique key as a raw database error. Administrative
operations (delete(), assertDeletable(), rotate(), store(),
getMetadata()) now use the disabled-visible lookup, and
buildSecretEntity() carries hidden forward — without which routing
store() through the wider lookup would have silently re-enabled a disabled
secret on any value write. findByIdentifier() itself is untouched, so the
read path is unchanged. The list template's "Disabled" badge, row class and
active/disabled filter had been dead against a hardcoded 'hidden' => false
and a comment claiming secrets have no hidden state; both are gone.LocalEncryptionAdapter::delete() did nothing at all for a disabled
secret (#278) — a silent no-op, because it went through the
restriction-honouring lookup.allowed_groups, write_groups, expiry, scope and
frontend availability that DataHandler had just persisted; a plain
programmatic store('id', $value) silently reset policy fields whose change
is gated by secret.manage_policy. Updates preserve unsubmitted fields now.
In the same area, a FormEngine create was classified as an update because the
row exists before store() is called, so it was gated by secret.rotate and
audited as update; creation is now classified by the value — a record
without an encrypted value is a creation in progress.Secret parsed the allowed_groups / write_groups count columns as a
list of group uids when the MM load came back empty (#261), so a count of 3
would have read as "group 3 is allowed". The fallback had no legitimate
producer and is gone; the write side emits the relation count consistently.EncryptionService::resolveAlgorithm() accepted any encryption_version >= 2 and opened it under version-2 rules (#242) — forward compatibility by
accident, so an envelope claiming version 99 decrypted "successfully".
Unimplemented versions are refused loudly; versions 1 and 2 are unchanged.wipeCredentials() raised instead of being idempotent (#258).
sodium_memzero() nulls the zval after zeroing, so a second call threw
SodiumException — on a failure path, where it replaced the real error with
its own. A guard flag makes repeat calls the no-ops the docblock always
promised.Documentation/Usage/Index.rst showed TEXT
objects whose only property was value. TEXT removes value from its
configuration before rendering, so such an object never calls stdWrap() and
the placeholder never resolved. The examples now carry the stdWrap.
sub-array that makes them work.VaultService held a request-scoped plaintext cache consulted before
the record load, the per-secret canRead() tier, the interactive secret.use
gate, the expiry check and the read audit entry — and VaultService is a
shared singleton. In a long-running worker the concrete failure is three
steps: technical actor A reads secret X and the plaintext lands in the cache;
the runAs() scope switches to actor B; actor B retrieves the same identifier
and gets the plaintext back with no authorization, no expiry check and no
audit row. The cache key carried neither actor nor context nor permission
state, and the cache was only reliably emptied in the destructor. It is
removed entirely; see Changed for the API consequence.VaultService::store(), rotate() and delete() checked the per-secret
tiers alone, and tx_nrvault_secret is an ordinary visible TCA table — so a
backend user with generic table rights could create, rotate or delete secrets
through a direct FormEngine or DataHandler request without ever meeting
secret.create, secret.rotate or secret.delete, and change policy columns
without secret.manage_policy. Enforcement now sits at the business boundary
with audited denials: store() requires secret.create for a new identifier
and secret.rotate for an existing one, plus secret.manage_policy when the
call actually changes owner, group tiers or frontend availability — compared
on the effective values after the existing coercions, not on what was
submitted. SecretTcaHook carries the same gates for the two FormEngine paths
that do not pass through the service.catch (Throwable) {}); a metadata update kept its database change when the audit write failed,
on a "don't fail the save" rationale. The auditor documentation's claims —
every mutation logged, delete and store compensate a failed audit write —
simply did not hold for the DataHandler path. The honest failure contract came
first: AuditLogService::log() now wraps any chain-write failure in
AuditWriteException, where previously only the advisory-lock timeout was
wrapped, so a genuine INSERT failure bypassed every compensating rollback that
catches that type. On top of it, the tx_nrvault_secret delete command runs
through VaultService::delete() and core's deleteAction is skipped in every
outcome; metadata changes revert their captured pre-change values when the
audit write fails; and a failed vault delete now cancels the record delete
on foreign tables instead of proceeding and orphaning the secret behind an
apparently successful removal.SecretRepository loads effective groups from the MM tables, and the
audit-failure rollback only restored the tx_nrvault_secret row — so an ACL
widening whose audit write failed persisted unaudited, while the restored
count column actively contradicted the MM state. Both tiers are now
snapshotted in processDatamap_preProcessFieldArray(), which is the only
viable moment because DataHandler's writeMM() runs inside checkValue(),
before the audit hook exists to fail. A tier that legitimately had no groups
is restored to empty, and if a tier cannot be repaired the DataHandler log
says NOT revertible instead of falsely reporting success. For
status='new' the MM writes are deferred past the hook, so the revert used to
leave orphaned rows against the deleted uid; a new
processDatamap_afterAllOperations() pass purges them.secret.create fell into
the value-less branch: the row survived with owner_uid forced to the denied
user, the identifier was reserved against later legitimate creators, and a
create success=1 entry went into the tamper-evident chain next to the
truthful access_denied one. Classification is now explicit
(RecordCreationOutcome::classify() → ValueLess / Stored / Rejected), a
rejected create deletes the fresh row and joins the MM purge, and no success
entry is written — the access_denied entry is the record.secret.create entirely (#270). Both create
gates live inside VaultService::store(), and the value-less path is by
definition the one where store() is never called; secret_input is optional
in the TCA, so a backend user with tables_modify but without secret.create
could deliberately leave the value empty, create a vault record, become its
owner, reserve the identifier, and produce a successful create entry in the
chain for an operation they were not permitted to perform. No race, no
misconfiguration. The gate now sits in processDatamap_preProcessFieldArray()
and refuses before the row exists, using core's documented abort contract
($fieldArray = null → if (!is_array($incomingFieldArray)) { continue 2; },
present in v12.4, v13.4 and v14.3), so nothing is inserted and nothing needs
compensating.metadata_update attributed to the editor — the log asserting
that a change was authorised when it was not. Two concrete abuses followed.
Backdating expires_at takes a foreign secret out of service for every
consumer, and setting it to 0 revives a retired one. Writing metadata is
worse: OrphanCleanupTask reads table and uid straight out of that column and
recordExists() answered false for a table that does not exist, which the
caller reads as "source record gone, retire the secret" — so a crafted payload
on a secret past the retention cutoff made the scheduler delete it, with the
task as the recorded actor. That is destruction, not denial of service. The
hook now resolves the Secret and requires canWrite() before anything is
written, refusing the whole record; expires_at and metadata join the
privileged column set; orphan cleanup fails closed, so only a successful
lookup against an existing table returning no row may answer "gone"; and
SecretsController::toggleAction, which gated the operation permission alone
and let any holder hide or unhide any secret, checks canWrite() too.
Reachability was narrower than "any editor" — vault-created secrets live at
pid 0, which core refuses to non-admins before the hook runs, so the defect
needed a secret row on a real page — and the control gap and the false audit
entry are real regardless.SecretNotFoundException counted as failure, which made a record referencing
a missing secret permanently undeletable through the backend,
self-reinforcing with the copy bug. Copy now compensates every already-cloned
secret and blanks all vault fields of the copy on any failure; delete
pre-flights every field through the new non-mutating assertDeletable(),
which shares delete()'s private gate so the two cannot drift, and a missing
secret counts as success. The residual is stated rather than implied: this is
preflight plus best-effort compensation, not atomicity (#267).undelete restored a soft-deleted secret with no vault check of any kind
(#276) — no ACL, no operation permission, no audit entry. The vault delete
writes only deleted = 1, so ciphertext, DEK, frontend_accessible, hidden
and both MM ACL tiers survive intact, and restoring the row brings all of it
back; a previously frontend-accessible secret is immediately resolvable again.
The prerequisites were an authenticated non-admin with tables_modify,
workspace 0 and one uid — zero vault permissions — because core's
undeleteRecord() gates page permissions behind if ($recordPid > 0),
skipped entirely at pid 0, and SimpleDataHandlerController passes cmd
through with no allow-list. Core does write sys_log and sys_history, so
the restore was invisible specifically to the chain auditors are pointed at.
copy and move are refused alongside it: a copied secret is always
value-less while carrying the original's identifier, and findByIdentifier()
has no ORDER BY, so the empty clone can win the lookup and break an intact
secret; move is the only command that takes a secret off root level into the
page tree, where deleteSpecificPage() removes records by pid through a path
that reaches neither the vault ACL nor the audit log.DELETE FROM tx_nrvault_audit_log WHERE uid > N — or a full TRUNCATE — left a
self-consistent chain that every tamper-evidence control reported as valid.
nr_vault now records one signed assertion outside that table, in the core
table sys_registry: "row uid = A still exists and its entry_hash is
still H", authenticated with a key derived from the master key under its own
HKDF context. Tail truncation, deletion of the last row, a full wipe, and a
wipe followed by refilling the same UIDs are all reported as an invalid chain
now. There is no database schema change. What an attacker without the master
key cannot do is forge the assertion, so the one-statement invisible
truncation becomes a two-target attack whose second target can only be
destroyed — and destruction changes the reported verdict.%vault()% placeholders are now scoped to published identifiers
(ADR-035, #235). TypoScriptVaultListener runs on the output of every
stdWrap() call, so an editor-written tt_content field (stdWrap.field = bodytext) or a reflected request parameter (data = GP:q) was a resolution
site for any secret flagged frontend_accessible — the plaintext landed in
output shared through the page cache. In a frontend request the extension now
resolves an identifier only when it was published through a source an editor
cannot write: the TypoScript setup array, the site configuration or settings,
plugin.tx_nrvault.frontendResolvableIdentifiers, or
FrontendPlaceholderPolicyInterface::allowIdentifier(). The check runs before
the vault is touched, so a rejected identifier reaches neither the vault nor
the audit log.vaultSecret FlexForm field were stored in cleartext
(HIGH): the hook resolved the data structure with an empty table name, an
empty field name and the submitted array where the record row belongs, which
throws on both v13 and v14 — and the exception was swallowed, so the editor's
plaintext fell through to DataHandler and into the record XML with no vault
ACL and no audit entry. Frontend authorization was inferred from the absence
of $GLOBALS['BE_USER'], which TYPO3 populates for any visitor carrying a
backend session, so an editor could put a placeholder for a secret they cannot
read into a published page, have an admin review it in the frontend, and the
decrypted value went into the shared page cache and out to anonymous visitors.
The SSRF DNS-pinning middleware returned "allowed, nothing to pin" for
canonical IP literals, trusting a caller-side check that redirect hops never
pass, so a 302 to 169.254.169.254 reached cloud metadata. An unbounded
X-Request-Id went into a varchar(100) column while the HMAC covered the
untruncated value, which either aborted the audit write or left a row
permanently disagreeing with its own hash. Both CSV exports emitted the
proprietary fputcsv escape, letting an attacker-controlled field close its
own cell and synthesize a formula cell past the sanitizer; both now emit
strict RFC 4180. And vault exception text reached editors through the flash
message and DataHandler::log(), giving an existence oracle for secrets
outside their ACL — failures now report a generic message plus a correlation
reference, with the cause logged server-side, while the TSconfig edit /
readOnly field permissions are re-checked on the write path instead of only
rendered as a readonly attribute.visibilitychange and pagehide; the reveal modal wipes its input on every
close path, including ESC and backdrop. AjaxController::revealAction sends
Cache-Control: no-store on success and error. Under the hardened profile
copy-to-clipboard is disabled outright, because the clipboard outlives the
dialog and cannot be reliably cleared from JavaScript. The guarantee is
documented for what it is — a bounded exposure window, not memory clearing,
since JavaScript strings cannot be zeroized. An orphaned SecretReveal.js,
whose DOM ids appeared in no template, PHP file, configuration or test, was
deleted rather than hardened.Everything below is something an operator has to do; nothing here happens by itself.
tx_nrvault:<permission> custom option on one of their
groups (Backend Users module). The one that bites quietly: non-admin editors
who work with vault-backed FormEngine or FlexForm fields now need
secret.use. Admins are unaffected unless you also set
disableAdminOverride.cliAllowedOperations. The default is
secret.use,secret.create,secret.rotate. Anything that reveals
(vault:retrieve), deletes (vault:delete, the scheduled orphan cleanup),
exports (vault:audit --export), rotates the master key, reads or verifies
the audit log (vault:audit, vault:audit-verify), or publishes the tip
anchor (vault:audit-anchor) needs allowCliAccess = 1 and the operation
added to that list. Prefer a named technical actor via
TechnicalActorContext::runAs(): grants then come from its provisioned groups
and the audit trail names the identity that read, exported or anchored, rather
than recording an unattributable shell.scheduler:run authenticates the _cli_ administrator, who passes through
the admin bypass. Under disableAdminOverride that bypass is gone by design,
so the identity running the scheduler needs a group carrying
tx_nrvault:audit.view (verify) and tx_nrvault:vault.configure (anchor);
without it both tasks fail loudly rather than skipping quietly. That is the
intended trade: a red scheduler entry is recoverable, an anchoring run that
did not happen but looks like one that did is exactly what the anchor exists
to rule out.lib.apiKey.value = %vault(my_api_key)% publishes
my_api_key. An identifier used only in a Fluid template file, a userFunc
or a DataProcessor is not in the setup array — publish it once per site with
plugin.tx_nrvault.frontendResolvableIdentifiers = my_api_key. A rejected
placeholder is left literal in the output, and the Development context emits
one notice per request naming it.allowIdentifier() with the PSR-7 request as its
second argument:
GeneralUtility::makeInstance(FrontendPlaceholderPolicyInterface::class)->allowIdentifier('my_api_key', $request). The policy is a shared service that outlives a request, so the
grant is stored against that request object in a WeakMap — unreachable from
any later request, which in a worker SAPI (FrankenPHP, RoadRunner) is what
stops one request's grant from authorising the next one's anonymous,
page-cached render. Pass the request you are handling and setRequest() the
same object on the content object renderer you render with; the grant is
matched by object identity. $GLOBALS['TYPO3_REQUEST'] is never used for it.
A renderer carrying a different request, or none, resolves nothing.frontendPlaceholderLegacyCli = 1 to
restore the old CLI behaviour — and expect vault:doctor to report that flag
as a warning under standard and a critical under hardened, because no
workflow needs it that publishing the identifier would not also serve.VaultServiceInterface::clearCache() call and any cacheEnabled
configuration. Both are gone. Reads are no longer cached at all, so a
consumer that read the same identifier in a tight loop now performs one
SELECT and one decrypt per read — and produces one audit row per read, which
is the point.VaultServiceInterface,
SecretRepositoryInterface, VaultAdapterInterface or
VaultDoctorServiceInterface need the new members listed in the interface
table under Changed. The two new parameters are optional and default to
today's behaviour, so only the new methods are a hard break.undelete is no longer available for tx_nrvault_secret, for anyone,
administrators included. If you rely on restoring soft-deleted secrets, that
path is now the database — where the change is visible as what it is. Update
any runbook that promised the delete was reversible.auditAnchorRequired after the first audit write following the
upgrade, not before: installations arrive with a populated chain and no
anchor row, which is a warning while the flag is off and an error once it is
on. While it is on, vault:audit --reset-anchor is the only way to arm the
anchor, and it writes the reset into the chain so the operation cannot be
performed invisibly.HTTP.allowed_hosts entry. The sink uses the
SSRF-guarded client and the refusal is loud — logged, counted and raised as a
finding — but it is a refusal.vault:doctor --profile=hardened before switching a profile, and
vault:doctor --active-probes after configuring sinks. The first tells you
what would fail without changing any configuration; the second is the only
check that proves records actually arrive rather than that a URL parses. Exit
codes are stable (0 clean, 1 warnings, 2 critical), so both are usable as
pipeline gates.auditHmacEpoch is below 3, raise it and run the migration — in that
order matters less than doing both. Raising the setting without running
vault:audit-migrate-hmac leaves older rows signed at the lower epoch and is
the silent case doctor now reports as a warning; at epoch 1 the success
column itself is outside the signed payload, so a recorded denial can be
flipped to a recorded grant without touching a signed byte. Epoch 0 is
critical: it drops row hashes to keyless SHA-256, makes the downgrade guard
vacuous, and disables the in-DB anchor even when auditAnchorRequired is set.Two pull requests, one theme: making the vault usable by other extensions without each of them re-inventing the parts that must not be re-invented. Re
Two pull requests, one theme: making the vault usable by other extensions without each of them re-inventing the parts that must not be re-invented. Read the 0.13.0 changelog for the full detail.
EncryptionServiceInterface protects a secret across seven arguments and a column per field. That shape is right for tx_nrvault_secret and wrong for a consumer that keeps one encrypted payload in one column — so consumers were inventing framing around it. The new portable envelope codec (EnvelopeCodecInterface, ADR-032) gives them seal() / open() / isSealed() / rewrap() against a self-describing format (nrv1: + base64 JSON) instead.
This is the part to read if you already have a consumer sealing data. Rotation re-wrapped the data keys in tx_nrvault_secret and nothing else. A consumer's wrapped data key lives in its own table, so rotating left those envelopes under exactly the key the command's next-steps output tells the operator to destroy — silently, because the rotation succeeded at everything it knew about.
Consumer-owned envelope rotation (ForeignEnvelopeRotatorInterface, ADR-033), found and fixed by @CybotTM in #230, closes it: an extension tags an implementation nrvault.foreign_envelope_rotator, and vault:rotate-master-key re-wraps its envelopes inside the same transaction as its own secrets, handing over keys as an EnvelopeRotationContext so the consumer never holds key material. The command now reports every participant and refuses to run when it cannot inventory one, when a consumer's table sits on a different database connection, or when a rotator re-wraps fewer envelopes than it reported.
If you maintain an extension that seals payloads with nr-vault, registering a rotator is required from this release on.
The knowledge of what a secret looks like was maintained in four places — this extension's plaintext scanner and three separate redactors in nr-llm — and the copies had drifted apart in both directions. SecretPatternLibrary (ADR-031) merges them, carrying an anchored form per shape for classifying a whole value and an inline form for masking a secret embedded in free text; SecretRedactor is the consumer-facing API.
The merge immediately fixed redaction bugs that had been live in the drifted copies:
client_secret was never redacted. The parameter-name alternation had to match immediately after ? or &, so the name RFC 6749 §2.3.1 actually defines passed through untouched — as did password and the hyphenated api-key.& and whitespace, a ?token=… inside a JSON payload swallowed the closing quote and the following key; a URL carrying a port followed later by an unrelated address was read as one userinfo component, losing the port and fabricating a credentialled URL to a host that was never contacted.ghs_ tokens and fine-grained GitHub PATs as Critical instead of leaving them unlabelled.MasterKeyRotatedEvent is dispatched. It was declared, documented in Api.rst and listed as step 3 of the rotation procedure in ADR-003, but fired from nowhere. It now dispatches after the rotation commits, carrying the consumer-envelope count alongside the secret count.
The documented decrypt() and reEncryptDek() signatures in Api.rst had drifted five parameters behind the interface, and were corrected.
Full Changelog: v0.12.2...v0.13.0
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.13.0.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.13.0.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
Netresearch\\NrVault\\Secret, ADR-031).
The knowledge of what a secret looks like was maintained in four places — this
extension's plaintext scanner and three separate redactors in nr-llm — and the
copies had drifted apart in both directions. SecretPatternLibrary merges
them, carrying an anchored form per shape for whole-value classification and an
inline form for masking a secret embedded in free text. SecretRedactor is the
consumer-facing API. Consuming extensions can read the catalogue statically or
resolve SecretRedactorInterface from the container.EnvelopeCodecInterface, ADR-032). seal() /
open() / isSealed() / rewrap() protect a payload a consumer keeps in ONE
column, so it no longer has to invent framing around
EncryptionServiceInterface's seven-argument, column-per-field shape. The
sealed form is nrv1: + base64 JSON.ForeignEnvelopeRotatorInterface,
ADR-033). A consuming extension tags an implementation
nrvault.foreign_envelope_rotator; vault:rotate-master-key then re-wraps its
envelopes inside the same transaction as its own secrets, handing over the keys
as an EnvelopeRotationContext so the consumer never holds key material.tx_nrvault_secret only. A consuming extension's
wrapped data key lives in its own table, so rotating left those envelopes under
a key the operator was told to destroy — silently, because the rotation
succeeded at everything it knew about. Any consumer that seals payloads must
now register a rotator; the command reports each participant and refuses to run
when it cannot inventory one, when a consumer's table sits on a different
database connection, or when a rotator re-wraps fewer envelopes than it
reported.MasterKeyRotatedEvent is dispatched. It was declared, documented in
Api.rst, and listed as step 3 of the rotation procedure in ADR-003, but was
never dispatched from anywhere. It now fires after the rotation commits and
carries the consumer-envelope count alongside the secret count.? or &, so
client_secret — the name RFC 6749 §2.3.1 defines — never matched and passed
through untouched. password and the hyphenated api-key were missing for the
same reason.& and whitespace, a ?token=… inside a JSON payload swallowed the
closing quote and the following key, and a URL carrying a port followed later
by an unrelated address was read as one userinfo component — losing the port
and the following field, and fabricating a credentialled URL to a host that was
never contacted.ghs_ tokens and fine-grained GitHub PATs in a scanned column or configuration
key are now reported as Critical instead of unlabelled.decrypt() and reEncryptDek() signatures in
Api.rst, which had drifted five parameters behind the interface.Patch release fixing the audit-logging error that appeared on every re-save of an existing secret record.
Patch release fixing the audit-logging error that appeared on every re-save of an existing secret record.
metadata_update audit action, which the audit log rejected as unknown — the editor saw the error on every such save, and the audit entry for the metadata change was silently never written. The action is now a valid audit action: metadata-only saves are sealed into the tamper-evident audit chain and appear as "Metadata Update" in the audit-module action filter. The path became reachable with the v0.12.1 fix that preserves an untouched secret on re-save. (#228)Regression coverage was added at both levels: a functional test asserting the clean save plus the sealed audit entry, and a Playwright E2E test replaying the backend flow from the report.
Thanks to @lradloff for reporting #227.
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.12.2.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.12.2.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
metadata_update,
but the AuditAction enum had no such case, so the audit write was rejected
— the save succeeded, yet the audit entry for the metadata change was
silently never written. The action is now a valid enum case and the change
is sealed into the tamper-evident audit chain; it also appears as
"Metadata Update" in the audit-module action filter (#227).A patch release fixing a data-loss bug in vault secret fields.
A patch release fixing a data-loss bug in vault secret fields.
Thanks to @lradloff for reporting #223.
All changes since v0.12.0composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.12.1.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.12.1.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
Six findings from a full security review — one HIGH and five MEDIUM. Two carry behaviour changes; see Upgrade notes below before upgrading.
Six findings from a full security review — one HIGH and five MEDIUM. Two carry behaviour changes; see Upgrade notes below before upgrading.
%vault(id)% references in site configuration were resolved eagerly when TYPO3 loaded the site configuration, and TYPO3 persists that array into its on-disk core cache — so decrypted secrets landed in var/cache in cleartext, with the per-principal access check applied only once, at cache-warm time. Resolution is now caller-driven, at read time.User-Agent, request id, identifiers) reached CSV exports without neutralizing spreadsheet formula leaders (= + - @, tab, CR); all four export sinks now neutralize them.tx_nrvault_secret (#220). Deleting a secret via DataHandler (FormEngine, list module) enforced no vault ACL; it now requires owner / admin / system-maintainer.0600 from the start (#219). vault:retrieve --output and vault:init left a world/group-readable window between the write and the chmod.%vault()% references now resolve at read time, not automatically on load (#216, ADR-030). Code that read $site->getConfiguration()[…] and received a decrypted value must now call SiteConfigurationVaultProcessor::processConfiguration($config, $site) explicitly.AuditLogServiceInterface gained verifyChainForReseal() and a verifyHashChain() $minEpoch parameter (#221); external implementers of the interface must implement the new method.vault:retrieve / vault:init now abort on a failed chmod (#219), and a vault-ACL-denied delete now preserves the record and logs access_denied instead of soft-deleting (#220).composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.12.0.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.12.0.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
Security release. Six findings from a full security review, one HIGH and five MEDIUM. Two carry behaviour changes — read Changed and Removed before upgrading.
%vault(id)% references in config/sites/*/config.yaml
were resolved eagerly when TYPO3 loaded the site configuration, and TYPO3
writes that resolved array into its on-disk core cache — so decrypted
secrets landed in var/cache in cleartext, and the per-principal access check
ran only once, at cache-warm time. Resolution is now caller-driven at read
time (see Removed).User-Agent, request id, identifiers) were written to CSV
exports without neutralizing spreadsheet formula leaders (= + - @, tab, CR).
All four export sinks now route cells through a shared neutralizer.tx_nrvault_secret (#220). Deleting a secret
via DataHandler (FormEngine, list module) enforced no vault ACL; it now
requires owner / admin / system-maintainer, mirroring VaultService::delete().0600 from the start (#219).
vault:retrieve --output and vault:init wrote the file with the process
umask and only chmod-ed afterward, leaving a world/group-readable window.%vault()% references resolve at read time, not
automatically on load (#216). Consumers that relied on transparent
resolution — reading $site->getConfiguration()[…] and receiving a decrypted
value — must now call
SiteConfigurationVaultProcessor::processConfiguration($config, $site) at the
point of use. See ADR-030.AuditLogServiceInterface: verifyHashChain() gains an optional
?int $minEpoch = null parameter and a new verifyChainForReseal() method
(#221). External implementers of the interface must implement the new method.vault:retrieve / vault:init abort on a failed chmod (#219) instead
of silently continuing.access_denied audit entry, instead of being soft-deleted.SiteConfigurationVaultListener — the event listener that eagerly
resolved site-configuration vault references on load (#216). See Security
above for why, and Changed for the read-time replacement.A corrective patch that restores the extension documentation pipeline.
A corrective patch that restores the extension documentation pipeline.
Documentation/guides.xml — the file was corrupted and broke rendering on docs.typo3.org. The documentation now builds and renders again..github/workflows/docs.yml) so a broken or malformed guides.xml is caught in CI before it reaches a release.The backend dashboard widgets and the dark-mode fix introduced in 0.11.3 remain in place; this release adds no new features and changes no behaviour.
This is an additive 0.x patch release, so projects requiring ^0.11 pick it up automatically.
Dashboard widgets (new): two admin-only widgets — nrvault-secrets (active-secret count) and nrvault-audit-activity (14-day audit-event bar chart). Reg
nrvault-secrets (active-secret count) and nrvault-audit-activity (14-day audit-event bar chart). Registered only when typo3/cms-dashboard is installed (guarded, no hard dependency). (#212)Additive/non-breaking 0.x patch so ^0.11 consumers pick it up.
Completes the 0.11.1 translation fix: copy-mode localization now also works for records with FlexForm vault fields.
Completes the 0.11.1 translation fix: copy-mode localization now also works for records with FlexForm vault fields.
0.11.1 fixed the error thrown when translating a content element into another language in copy mode — but only for regular vault fields. The same error still occurred for records carrying vault secrets inside FlexForm fields (reported as a follow-up on #207). Both paths now work; this was the last occurrence of the underlying issue.
Drop-in upgrade: no configuration change, no database migration. Affects all versions up to and including 0.11.1.
Fixes #207 (#210). Thanks to @lradloff for reporting.
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.11.2.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.11.2.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
DataHandlerHook; the same
bool-typed $pasteUpdate parameter remained on
FlexFormVaultHook::processCmdmap_postProcess and threw the identical
TypeError when translating records with FlexForm vault fields in copy
mode. The parameter now accepts bool|array — the last remaining
bool-typed $pasteUpdate in the extension.Translating content into another language in copy mode works again.
Translating content into another language in copy mode works again.
Using the Translate button to copy a content element into another language failed with an error, and no translation was created. Copy-mode localization now completes.
Drop-in upgrade: no configuration change, no database migration. Affects all versions up to and including 0.11.0.
Fixes #207 (#208). Thanks to @lradloff for reporting.
composer require netresearch/nr-vaultAll release artifacts are signed with Sigstore keyless signing.
cosign verify-blob \
--bundle nr-vault-0.11.1.zip.sigstore.json \
--certificate-identity-regexp "https://github.com/netresearch/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
nr-vault-0.11.1.zipsha256sum -c checksums.txtSBOMs are provided in both SPDX and CycloneDX formats for supply chain transparency.
DataHandlerHook TypeError when translating content in copy mode (#207,
#208). TYPO3 core reassigns the $pasteUpdate hook argument from its false
default to an array on the localize / copy-to-language path, so the bool
type on processCmdmap_preProcess() and processCmdmap_postProcess() raised
a TypeError before the command guard ran, breaking content-element
translation via the records/localize AJAX endpoint. The parameter now
accepts bool|array, matching core's runtime contract.`TechnicalActorContext::runAs()` (#202, #205). Headless consumers (Symfony Messenger workers, scheduler tasks) can evaluate vault access as a named te
TechnicalActorContext::runAs() (#202, #205). Headless consumers
(Symfony Messenger workers, scheduler tasks) can evaluate vault access as a
named technical backend user without mutating $GLOBALS['BE_USER']: the
scoped context is consulted directly by AccessControlService with the same
user semantics an authenticated backend login gets (owner, admin, group ACLs
with subgroup expansion). Actor validation is fail-closed (deleted, disabled,
time-restricted and non-rootLevel users are rejected before the scope
starts), nesting stacks innermost-wins, the identity is always restored on
scope exit, and audit rows record the actor as technical. Ambient behavior
without an active scope is unchanged.CLI access control reachable from queue workers and scheduler (#201). The TYPO3 CLI bootstrap places an unauthenticated CommandLineUserAuthentication
CommandLineUserAuthentication
in $GLOBALS['BE_USER']; AccessControlService treated it as a backend user,
which shadowed the configured CLI access rules — making them unreachable from
Symfony Messenger workers and scheduler runs — and let its default uid 0 match
ownerUid=0 secrets, granting read and delete even with CLI access disabled.
The unauthenticated placeholder is now routed to the CLI access rules;
authenticated _cli_ users keep their user-based semantics and web requests
are unaffected.Legacy numeric IP-literal SSRF bypass closed (#192). curl's resolver accepts inet_aton() forms — dword (2130706433), octal (0177.0.0.1), hex (0x7f.0.0
Legacy numeric IP-literal SSRF bypass closed (#192). curl's resolver
accepts inet_aton() forms — dword (2130706433), octal (0177.0.0.1),
hex (0x7f.0.0.1) and partial-dot (127.1) — that all reach 127.0.0.1,
but PHP's inet_pton() / FILTER_VALIDATE_IP reject them, so they slipped
through the dangerous-IP guard as pseudo-hostnames (no DNS record, no pin)
and curl derived the internal IP itself. Such forms are now rejected both in
isHostAllowed() and in the request-time SSRF middleware unless the operator
allowlists the exact literal; the canonical dotted-quad / IPv6 form is
unaffected.
Privileged secret ACL columns are now authorization-gated (#186). On the
secret FormEngine write path, the owner_uid, allowed_groups,
write_groups, frontend_accessible and scope_pid columns of
tx_nrvault_secret carried no exclude flag and no admin gate, so a
non-admin editor could widen a secret's ACL or reassign ownership
(privilege escalation, CWE-639 / CWE-269). The write path now enforces
owner/admin authorization on those privileged columns (also closes a minor
in-memory cleartext exposure, CWE-316).
Audit hash-chain tamper-evidence hardened (#185). Two forgery paths
reachable by the documented in-scope database attacker (a principal with
UPDATE/DELETE on tx_nrvault_audit_log) are closed: an epoch-downgrade
that allowed the tail row to be rewritten under a keyless SHA-256 epoch
because the hmac_key_epoch was not bound into any hashed payload
(CWE-345 / CWE-757), and an attribution forgery. Both are now detected by
verifyHashChain().
The vaultSecret field description no longer renders twice on v14 (#189).
TYPO3 v14's AbstractFormElement::renderLabel() emits the TCA description
itself (via renderDescription()), so VaultSecretElement's own copy became
a duplicate. The element now renders its own description only on v13, where
the label does not — so it appears exactly once on both v13 and v14.
The SSRF middleware no longer fatals without ext-curl (#192). It
referenced the curl-only CURLOPT_RESOLVE constant unconditionally, so the
first pinned request on a curl-less install raised Error: Undefined constant "CURLOPT_RESOLVE" — contradicting create()'s documented
StreamHandler-fallback warning. The pin is now attached only when
curl_init() exists; the dangerous-IP rejections still run.
Dual-stack hosts are reachable again from IPv6-less environments (#190).
The DNS-rebinding defence pinned each resolved address as a separate
CURLOPT_RESOLVE entry, but curl keeps only the last entry per host:port
— so effectively only the final DNS record (typically the AAAA) was pinned.
On hosts without IPv6 connectivity every request to a dual-stack host
(e.g. api.github.com) failed with cURL error 7 and no IPv4 fallback;
all-IPv4 multi-record hosts silently lost their fallback addresses too.
All safe resolved addresses now travel comma-joined in a single resolve
entry (curl's multi-address form, curl ≥ 7.59), restoring curl's native
cross-family/cross-address connect fallback while keeping the pin: every
usable address is still one the defence resolved and vetted.
Per-instance request timeout on the secure HTTP client. VaultHttpClientInterface::withTimeout(int $seconds) is a new immutable wither (like withReason
VaultHttpClientInterface::withTimeout(int $seconds) is a new immutable
wither (like withReason()): the override is baked into the hardened inner
Guzzle client via the shared factory, so it applies to every send path —
plain and authenticated — while connect_timeout stays platform-managed.
Non-positive values keep the platform default. Long-running upstream calls
(large image generations) no longer die at the instance-wide HTTP timeout.CommandLineUserAuthentication (a
BackendUserAuthentication subclass), so the backend-user-first check in
AccessControlService::getCurrentActorType() stamped every CLI/worker
access as backend. Analytics then counted 0 automated reads and flagged
busy automation secrets "Automation-stale". CLI detection now runs first,
and a latent constant-case fatal in the legacy CLI check was removed.runTests.sh defaults to the upper supported PHP bound (8.5) instead of
8.2; CI pins its matrix explicitly and is unaffected.Per-secret encryption-algorithm marker. Each secret now records how it was encrypted (encryption_version 2 + explicit algorithm in the new tx_nrvault_
encryption_version 2 + explicit algorithm in the new
tx_nrvault_secret.encryption_algorithm column). Decryption dispatches on
the stored marker instead of re-deriving the algorithm from the decrypting
host's CPU capabilities, so the same data decrypts identically on any PHP
host and future algorithm migrations become possible. Legacy rows (version 1,
no marker) keep decrypting byte-identically via the old host-derived path;
value rotation upgrades them to version 2. New secrets default to
XChaCha20-Poly1305; aes256gcm can be pinned via the new
encryptionAlgorithm extension setting (invalid or host-unavailable values
fail loudly).vault:rotate-master-key now
re-keys the whole chain inside the same transaction as the DEK
re-encryption (new AuditChainRekeyService, keyset-paginated for bounded
memory), preserving per-row epochs and refusing to re-key a chain that does
not verify under the current key. A second-rotation (epoch 2) functional
test covers consecutive rotations end to end.verifyHashChain() streams rows instead of materialising the whole audit
log; rotation pre-flight on large installs no longer risks OOM.dek_nonce/value_nonce columns widened varchar(24) → varchar(32): the
base64 form of a 24-byte XChaCha20 nonce is 32 characters (latent truncation
bug on the XChaCha20 path).encrypt()/decrypt() no longer sodium_memzero() the master key — it is
the provider's shared request-lifetime cache entry; the provider owns its
lifecycle. Per-secret key material (DEK, MAC key, plaintext) is still wiped
on every path, including exception paths.table__column__{{uid}} identifiers whose
literal braces failed validation — every backend-module column migration was
rejected.client_secret, refresh_token, and access_token.OAuthConfig rejects unknown grant types and a refresh_token grant
without a refresh-token secret at construction time.`prefix` option for `withAuthentication()` Header placement — prepends an auth scheme/prefix to the secret before injection, so non-Bearer Authorizati
prefix option for withAuthentication() Header placement — prepends an
auth scheme/prefix to the secret before injection, so non-Bearer
Authorization: <scheme> <secret> schemes can use the audited, memory-scrubbed
secure HTTP client instead of building the header manually with a plaintext key.
TYPO3 FAL providers use Key , DeepL uses DeepL-Auth-Key . The combined
prefixed value is zeroed alongside the raw secret; the no-prefix path keeps a
single secret buffer (no extra allocation). The option is threaded through
withReason(); the OAuth builder leaves it unset.Api.rst: documented the prefix option and added a custom-Authorization-scheme
example; corrected the DeepL usage example, which previously documented Bearer
(a scheme DeepL never used).Require TYPO3 v14.3 LTS instead of v14.0 for the v14 line (typo3/cms-* : ^13.4 || ^14.3). 14.0/14.1/14.2 were unsupported sprint releases; 14.3 is the
typo3/cms-* : ^13.4 || ^14.3). 14.0/14.1/14.2 were unsupported sprint
releases; 14.3 is the LTS. The CI matrix, README, and bug-report template
are aligned to the same constraint. (ext_emconf.php keeps its coarse
13.4.0-14.99.99 range — a single continuous range cannot express the
^13.4 || ^14.3 gap. Because nr-vault requires a composer-based TYPO3
installation, composer.json is the authoritative version constraint.)SecretRepository::findIdentifiers() now skips non-string identifier
rows instead of coercing them to an empty string. A driver/schema
anomaly that returned a non-string identifier previously injected a
bogus empty identifier into list views and rotation loops; such rows are
now dropped (an empty identifier is unreachable for valid data).Tests/scripts/check-cli-docs.php,
wired into composer ci as ci:test:php:doc-cli). It verifies every
documented vault:* example across README.md and the whole
Documentation/ tree against the command classes — unknown commands,
unknown options, and excess positional arguments fail the build. Backslash
line-continuations are joined so options on continuation lines are checked,
and both #[AsCommand(name: …)] and positional #[AsCommand(…)] forms are
recognised..gitattributes (export-ignore dev-only paths for smaller composer/TER
packages), .ddev/.gitignore, and a canonical .ddev/commands/web/setup
entry point.vault:seed-demo command — populates a development instance with
realistic, historic demo secrets and a matching audit-log history so the
Analytics module has lifelike data to show. Idempotent, refuses to run in
Production, and reseeds with --force.vault:* commands with
corrected argument signatures (vault:store --value=…, vault:audit --since=…).vault:* examples across the documentation that drifted from the
actual command signatures: vault:store value via --value/--metadata
(not --description/--context/--expires or a positional), vault:audit
--since/--until (not --days), vault:migrate-field positional
<table> <field> (not --table/--field), vault:rotate-master-key
--confirm/--new-key, the full vault:audit option reference, and
tx_vault_secret → tx_nrvault_secret.vault:seed-demo command in the CLI reference.`SecureHttpClientFactory`'s request-time SSRF middleware now honours literal `allowed_hosts` entries. In 0.6.0 the per-request DNS-rebinding middlewar
SecureHttpClientFactory's request-time SSRF middleware now honours
literal allowed_hosts entries. In 0.6.0 the per-request DNS-rebinding
middleware rejected every host that resolved into a private/loopback range
regardless of allowed_hosts, so the documented on-prem opt-in ("LITERAL
allowlist entries can opt back in") only applied to the isHostAllowed()
gate, not to clients built by create(). Consumers that reach an
internal/self-hosted endpoint through a create() client — e.g. an LLM
provider talking to a local Ollama at a private-resolving hostname — were
silently blocked with no way to opt back in. The middleware now applies the
same literal-allowlist check as isHostAllowed(); an allowlisted host whose
DNS answer is private is pinned via CURLOPT_RESOLVE instead of rejected, so
rebinding to a different address stays blocked. Wildcard allowed_hosts
entries still never bypass the guard.VaultService::store() now requires authorization. Previously any backend user with write rights on a host table carrying a vault field could create or
store() now distinguishes new vs. update and calls
canCreate() / canWrite($existing); denied paths emit an
access_denied audit entry and throw AccessDeniedException. Non-admin
backend actors that attempt to set or change owner_uid are silently
coerced to the default (existing owner on update, current actor on
create). CLI / scheduler / API actors retain full control.#[SensitiveParameter] rolled out across the crypto / DTO / audit
boundaries (0 → 35 occurrences). Plaintext secrets, master keys,
DEKs, OAuth tokens, refresh tokens and vault tokens no longer surface
in stack traces, error handlers, monolog payloads, or var_dump().
Applied to EncryptionService(Interface), MasterKeyProviderInterface
and all three providers, VaultService(Interface)::store/rotate,
AuditLogServiceInterface::log $hashBefore/$hashAfter,
PendingSecret::$value, FlexFormPendingSecret::$value,
VaultServerConfig::$token, and the private encryptWithKey /
decryptWithKey locals.SecureHttpClientFactory::isHostAllowed().
Regardless of allowed_hosts configuration, IP literals and
DNS-resolved hostnames pointing into private / RFC1918 / RFC6598 CGNAT
/ loopback / link-local / cloud-metadata (169.254.169.254) /
multicast / class-E / IPv6 ULA / IPv6 link-local / IPv6 multicast /
NAT64 / discard ranges are rejected. The check normalises
host:port, [ipv6]:port, bare ::1 (which parse_url misparses),
bracketed [2001:db8::1], trailing dots, mixed case, and whitespace.
LITERAL allowlist entries can opt back in for on-prem deployments
(e.g. '10.0.0.42'); wildcards (*.example.com) cannot — a wildcard
owner could otherwise pivot via DNS rebinding. The check resolves
hostnames at filter time; full DNS-rebind protection via
CURLOPT_RESOLVE pinning is a follow-up.VaultRotateMasterKeyCommand emits master_key_rotate_start before
the re-encryption loop and master_key_rotate_end (success or
failure) afterwards, both with a sanitised reason — error messages
are scrubbed of libsodium internals before persistence.auditReads filesystem-only override.
$TYPO3_CONF_VARS[SYS][nrVault][auditReads], if set, takes
precedence over the BE-toggleable extension configuration. Pin the
value in LocalConfiguration.php / additional.php on production so
a compromised admin cannot silence read logging via the BE Settings
module.Typo3MasterKeyProvider entropy gate. The default master-key
provider now rejects TYPO3 encryptionKey values shorter than 32
characters (would otherwise produce a weak HKDF output). Add a
request-lifetime static cache (ADR-020) so HKDF runs once per
request instead of on every crypto operation.FileMasterKeyProvider chmod race closed. storeMasterKey()
wraps the file_put_contents() call in umask(0o077) so the file
is created 0600, then chmod 0400 tightens further — no more
world-readable window under permissive umasks.MasterKeyProviderInterface::storeMasterKey(),
EncryptionServiceInterface::encrypt/decrypt/reEncryptDek/ calculateChecksum(), VaultServiceInterface::store/rotate(), and
AuditLogServiceInterface::log() now annotate sensitive parameters
with #[SensitiveParameter]. This is a signature change visible to
downstream implementers: PHP does not enforce the attribute on
implementations, but implementers should mirror it on their overrides
to keep the protection.AccessControlServiceInterface gains
isCurrentActorAdmin(): bool. New method delegating BE-admin check
to the service instead of $GLOBALS['BE_USER'] lookup. Returns
false for CLI / scheduler / API actor types — callers that need
to bypass admin gates must handle actor type explicitly.php-cs-fixer + lint actions. Pre-push retains unit-test execution.
Note: captainhook's installer does not currently support worktree
gitdirs (git clone --bare + worktree add); operators in worktrees
need to run vendor/bin/captainhook install -g <gitdir> manually.The meta-package also brings phpstan/phpstan-deprecation-rules, saschaegerer/phpstan-typo3, nikic/php-fuzzer, overtrue/phplint, and dg/bypass-finals t…
OAuthTokenManager::fetchTokenWithFallback() falls
back to client_credentials when a stored refresh_token is
rejected with HTTP 400/401 + invalid_grant / invalid_token.
5xx / 429 / invalid_client errors re-throw so outages are not
masked. Both the failed refresh and the fallback are audit-logged.vault:audit-migrate-hmac command for migrating legacy
SHA-256 audit entriesdisable flag is set, even when a stale session somehow
reaches the vault layer. Any non-zero integer / numeric-string
value is treated as disabled (matches TYPO3 DataHandler semantics).be_groups table before intersecting with a
secret's allowedGroups. A deleted group whose UID still lingers in
a session no longer grants access. Lookup is cached per request.previous_hash can no longer hide the deletion. New
missingUids / missingUidCount fields on the verification result.Tests/E2E/security/ bundle (XSS, audit
tamper, CSRF, cookie attributes, full CRUD lifecycle). See
Tests/E2E/USER_PATHWAY_COVERAGE.md for the full pathway audit
matrix.failOnWarning=true. First measured
baseline MSI: 72.35 % (thresholds set to 72 / 72 with a documented
ratchet plan toward 85 / 95 by Q4). See
Documentation/Developer/mutation-baseline.md.@main), concurrency block cancels stale PR runs, on-demand
mutation testing via the run-mutation PR label.netresearch/typo3-ci-workflows meta-package: 14 direct
require-dev entries reduced to 4 (mikey179/vfsstream,
netresearch/typo3-ci-workflows, roave/security-advisories,
typo3/cms-scheduler). The meta-package also brings
phpstan/phpstan-deprecation-rules, saschaegerer/phpstan-typo3,
nikic/php-fuzzer, overtrue/phplint, and dg/bypass-finals that
we did not previously have.AbstractVaultFunctionalTestCase,
TcaSchemaMockTrait, BackendUserMockTrait,
SecretFixtureBuilder, and a project Tests/Unit/TestCase.php base
class. 100 tests migrated. Architecture check script enforces the
base on new unit tests.SIGINT/SIGTERM/EXIT trap, collision-
resistant container suffix, Alpine base bumped 3.8 → 3.20, new
unitCoveragePath suite.VaultService::list()generateUuid and
looksLikeVaultIdentifier methodsvault:migrate-field --uid-field='' now fails fast with a
clear error instead of emitting an "Undefined array key" warning
mid-batch.httpStatus and oauthError
(parsed from the RFC 6749 §5.2 error body) so callers can
distinguish refresh-token rejection from server outage.Application::add() → addCommand()
across command tests (eliminates a deprecation warning).innerHTML sinks in frontend JS and
insecure test randomnessSecretReveal.js GET to POST and
EnvironmentMasterKeyProvider copy-on-write bugHelp Page: Add help page with docheader tab menu to backend module
TCA Element: Implement AJAX reveal and copy for vault secret TCA element
VaultSecretElement: Fix missing label, broken form submission, and silent errors
merge_group trigger to CI workflowTYPO3 v13: Add Overview submodule for v13 module overview compatibility
TYPO3 v13: Use integer values for f:be.infobox state for v13 compatibility
f:be.infobox state for v13 compatibilityTYPO3 v13: Use standard TYPO3 XLF label keys for backend modules
tools parent module for v13 compatibilityCompatibility: Widen support to PHP 8.2+ and TYPO3 v13.4+
ci:test:php:* conventionphpunit.xml, phpstan-baseline.neon) into Build/#[Override], typed class constants, and array_any() for PHP 8.2 compatibilityLLL:EXT: module labels for v13 compatibilityDocumentation: Add Secure Outbound HTTP Client PRD and ADRs
workflow_run trigger for SLSA provenance generationCI: Add TER upload to release workflow
runTests.sh with mock OAuth, E2E DDEV support, and parallel testsrunTests.shMOCK_OAUTH_URL env var in OAuth integration testsAllowMockObjectsWithoutExpectations for PHPUnit 12Documentation: Document all master key options in Installation
security.ymlnetwork_mode conflict in mock-oauth-routerTesting: Add comprehensive unit tests to reach 80% coverage
Core Vault Service: Secure secrets storage with CRUD operations
vaultSecret renderType for TCA fieldssodium_memzero()Your coding agent can read these notes before it upgrades. Set up the MCP server →