v1.7.0-rc.6
Full Changelog: v1.6.1-rc.6...v1.7.0-rc.6
[1.7.0-rc.6] — 2026-08-29
Added
Security
- An authenticated agent could still pre-empt a controller-local container before the controller wrote it. The ownership check landed for #904 only covers a container id the store already holds; a brand-new id had no check at all, so an agent could insert a record under a watcher name matching the controller's own local Docker watcher, race the controller's own watch cycle, and have
docker-trigger.ts's trigger routing (which selects purely on the storedagentfield) hand that container's future lifecycle actions to the agent instead of the controller. A no-record insert claiming a watcher name the controller registered directly, rather than through an agent, is now rejected. - That #904 fix only covered the two incremental
dd:container-*event handlers, not the bulk ingestion paths. Handshake, the watcher-snapshot fallback, on-demandwatch/watchContainer, and edgehandleContainerSyncall reachbuildContainerReportthroughprocessAuthoritativeContainerwith no ownership check in between, so an agent could still name an id owned by another agent or by the controller's own local watcher in its next routine snapshot and have the store reassign it, repeatedly, at its own cadence.processAuthoritativeContainernow runs the same no-existing-record and different-owner checks before ingesting. It deliberately skips the third checkcanMutateContaineralready has — rejecting awatchermismatch against the stored record — because bulk ingestion is exactly how a legitimate watcher rename propagates (an operator renaming aDD_WATCHER_<NAME>_SOCKETkey), and rejecting on that mismatch here would silently stop every future report for that container id from landing, with nothing left to prune or retry it. - Thirteen registries authenticated for the version check and then pulled anonymously. The pull-credential builder handled a login and password pair and a username and token pair, but had no branch for a configured
authvalue, while the sibling that builds lookup credentials did. So Hub, Custom, DHI, DOCR, Harbor, Gitea, Forgejo, Codeberg, Nexus, Artifactory, Alibaba CR, OCIR and IBM CR detected an update correctly and then attempted the pull with no credentials, which fails outright against a private registry and consumes the anonymous quota against a rate-limited public one. The value is decoded into a credential pair rather than passed through, because the Docker daemon reads the pair and never inspects anauthsub-field, and it is split on the first colon only since a password may contain one. A malformed value now fails closed rather than returning nothing, which would have silently reproduced the same anonymous pull for a different bad input. - A watcher failure on an agent returned the thrown message to the caller. Two agent handlers returned the raw message for anything that was not an
Error, which is the branch a rejected upstream request lands in with its context attached. Both now return the fixed generic message unconditionally; the detail still reaches the server-side log through the existing sanitizer. - A bulk security scan request had no cap on how many containers it could queue.
parseBulkScanBodyvalidated thatcontainerIdswas an array of non-empty strings but accepted it at any length with duplicates intact, and each accepted id drives a vulnerability scan in the handler, so an authenticated caller could send tens of thousands of ids, or the same id repeated, and make the controller do that much scanning work from one request. The siblingparseWatchContainersBodyalready caps and dedupes for the same reason; bulk scan now enforces the same 200-entry limit, checked before the per-element loop so an oversized array is rejected without walking it, and collapses duplicates with the sameSet-based dedup that silently drops a repeat instead of erroring. - Error responses could carry upstream credentials back to the caller. Eight handlers across the trigger, container and agent APIs interpolated a raw thrown message straight into the response body. That matters because of what those messages contain: a trigger failing against an upstream service throws with the request context attached, so an
Authorization: Bearerheader and a credentialed webhook URL both reached the client in a 500 that any authenticated caller could read. All eight now route through thesanitizePreviewErrorReasonscrubber that already existed for exactly this and had only ever been wired into the preview path. The two sites that deliberately tell an empty message apart from a missing one keep that distinction and scrub only a truthy string, so their synthesized fallback still fires. - The preview-error sanitizer missed credentials embedded in a URL path segment, not just in headers or userinfo. Telegram builds its API URL as
https://api.telegram.org/bot<credential>/sendMessageand IFTTT ashttps://maker.ifttt.com/trigger/<event>/with/key/<credential>, so a bot credential or Maker key sits in the path itself rather than behinduser:pass@or anAuthorizationheader. This is a latent gap, not an observed leak: nothing currently puts either provider's request URL into anerror.message, but a future throw that includes request context would carry the credential straight through.sanitizePreviewErrorReasonnow redacts the credential segment for both, plus the same shape in Discord webhook URLs (https://discord.com/api/webhooks/<id>/<credential>) found while checking the other providers, all anchored to each provider's own fixed host and path rather than to "any long opaque string" so an ordinary registry path segment, including a manifest digest, is left alone. - The API end-to-end suite could not detect an unmasked secret. Five assertions in
api-registry.featurechecked masked credential fields against.*, which passes for any value including a completely unredacted one.Component.mask()returns the literal[REDACTED], so all five now assert that, matching what the authentications feature already did.
Fixed
-
Row selection never highlighted anything in seven views. Servers, Registries, Watchers, Audit, Auth, Notifications and Triggers all passed an
active-rowprop to the shared data table, which declaresselectedKey. Vue drops an undeclared prop onto the root element and carries on, so the selection styling was a no-op in every one of them. The test suite was fine with it because each view's table double accepted the invented prop and echoed it back into an attribute the spec then asserted against, so the specs were checking the stub's own invention rather than the real component's contract. Coverage was 100% throughout. The doubles now require the real prop and each row-click test asserts the value actually changes. -
White text on a light accent, as low as 1.37:1. The trigger test button, the config profile avatar and the app-layout user menu avatar hardcoded white over a gradient whose far stop is the info or success color. Flattening to the solid primary token with its computed foreground clears 4.5:1 in all twelve themes, worst case 5.07:1. Keeping the gradient could not be fixed with the correct token, because the foreground token is computed against the primary color and not against the other stop. The contrast gate now also scans the sources for a tag combining white text with a primary-color reference, since token math cannot see inside a template, which is how this survived the previous contrast pass.
-
The notification outbox showed whichever tab's data resolved last rather than whichever tab was selected last, so switching quickly left the other tab's rows on screen. Guarded with the request-id pattern the triggers and notifications views already use.
-
Status was unreadable or untranslated in five places. The icon-only status indicators in Servers, Registries and Watchers had no accessible name, so state was conveyed by color and glyph alone. Auth, Audit, the container log header and the dashboard security widget interpolated raw enum values, so a non-English reader saw
active,success,errorandCRITICALin English regardless of locale. Five new English keys, translated into all 16 locales in the same change rather than left for a sync to fill with verbatim English. -
Removing a container while its full-page detail view was open left a blank screen. Two independent causes, either of which still reproduces alone. Closing the panel nulled the selection without clearing the full-page flag, so the layout stayed in full-page mode with nothing to render. And the watcher meant to notice the removal used a getter source: a watch with a getter source fires on reference change, not on in-place mutation, and the SSE pipeline removes by splice on purpose to avoid per-row invalidation, so the sync never ran and the selection kept pointing at a deleted object.
-
Two dashboard watchers missed every in-place SSE update. The pending-update rows and the operation holds both watched a bare ref, so neither ran while the patch pipeline mutated rows in place, leaving ghost update rows and holds that never released. The first takes a length-augmented source; the second watches a per-row fingerprint, because the fields it reads are patched without touching array length. Neither uses a deep watch, which would re-fire on every unrelated field mutation of every row and defeat the point of patching in place.
-
A malformed
authregistry value could still decode into credentials that looked valid.decodeAuthCredentialsfed the configured value straight toBuffer.from(auth, 'base64'), which silently drops characters outside the base64 alphabet instead of throwing:dXNlcjpwYXNz!still decodes touser:pass, the trailing!just discarded. That defeats the malformed-auth throw this decode exists to enforce, since a corrupted or truncated value can still land on a valid-lookinglogin:passwordsplit and fall through as real credentials instead of being rejected. The value is now re-encoded and compared against the original before decoding; any character the decoder ignored makes the round trip diverge and the throw fires. -
A rare upstream build race could fail a release cut with nothing to recover from. An open BuildKit bug (moby/buildkit#7089) makes a canceled-then-retried operation inside a single multi-architecture build get the QEMU emulator path prepended twice, so the build dies instantly with
Invalid ELF image for this architecturebefore producing any output. It fired once in CI and has passed on every run since, which is what a scheduler race looks like. The multi-architecture smoke build now retries, and the release cut gains a full-build retry for the specific case where the first attempt produced no digest at all. That case matters because the existing manifest retry needs a digest already sitting in a registry and hard errors without one, so before this the race firing during a real cut meant a hard failure discovered live in the release window. -
A long enough error killed the process instead of closing a log stream. The container log stream used the raw thrown message as the WebSocket close reason. The library throws synchronously once that exceeds 123 bytes and never catches it, and the stream is invoked as a floating promise with no rejection handler, so a registry error past the limit took the whole process down. Both remaining sites now truncate through the helper the file already had and one sibling was already using. The second of them was never a crash: it sat inside a
try/catchand so swallowed the error, and because the socket is marked closing before the throw, the close frame was never sent and the socket stayed open. -
Checking for updates again reset the image-maturity clock. Four agent entry points processed the authoritative container list and then pruned, so a container that had just been re-inserted was compared against a store that still held it. Pruning now runs first at all four. The watch path was also missing the zero-container guard the handshake path has, and needs it, because the Docker watcher returns an empty report array both when there genuinely are no containers and when enumeration threw and was swallowed, and the response cannot tell the two apart, so a transient socket error on an agent pruned every container for that watcher. (#565)
-
The docs search returned every archived version at once. A search for a common term returned about 1600 hits spread across five documentation versions, ranked with the oldest changelog first, so a reader on the current docs was served entries describing a configuration prefix this release removes. Each page is now indexed with its version and the search dialog scopes to the version being read, defaulting to the current one elsewhere.
-
Two badges on the homepage were blocked by the site's own content-security policy. The best-practices and coverage image hosts were missing from
img-src, so both rendered broken and logged a violation in any enforcing browser. Exactly those two origins are added, and the policy's full source list is now pinned by a test. -
The weekly DAST scan has never completed. The active scan alone consumed 39 minutes 46 seconds of the job's 40 minute budget, so the job was cancelled the moment the second scanner's steps opened: the second scanner has never run, the report file was never written, and the severity gate and SARIF upload both ran against a file that did not exist. The two scanners now run as separate parallel jobs with their own budgets, so neither can starve the other and a blown budget names the scanner responsible.
-
The action-filter documentation described shipped behaviour as scheduled. The callout still framed the include and exclude filters becoming hard blockers as future work, three release candidates after it shipped, so an operator reading it concluded they still had time to migrate labels while their update button was already being refused.
-
The
PUT /api/settingsdeprecation pointed consumers at a dead endpoint. The deprecation warning logged on every call, and the matching entry inDEPRECATIONS.md, both named the unversioned/api/settingspath. v1.6.0 removed the unversioned/apialias and settings is not one of the four wud-card compatibility endpoints exempted from that, so anyone following the migration advice moved off a deprecated method and onto a path that returns 410. Both now name/api/v1/settings, and the deprecations entry says outright that the unversioned path is not an alias for it. -
The UI is properly translated in all 16 non-English locales. Every locale already had every key, but between 17% and 29% of the values in each were still the English source text showing through, so large parts of the container list, the update and rollback dialogs, the search palette and the notification outbox rendered in English regardless of the language selected. 2109 strings are now actually translated. The 61 left alone are the ones with nothing to translate, such as
"{registryHost} — {error}", and category labels whose only word is a product name each locale already keeps in English. -
The image-maturity badge showed no age outside English.
Detected {duration} agohad replaced an older bareNEWlabel in the English source, but all 16 locales still carried a translation of the old wording, so the badge silently dropped its duration for every non-English user. Retranslated in all 16, and a new placeholder-parity gate now fails the build when a translated string loses or invents an interpolation placeholder, which is the class of bug that hid this one: it breaks neither JSON parsing nor key parity. -
A non-numeric
tailorsinceon the log and agent endpoints put NaN into the ring-buffer read instead of rejecting it. Both now validate and return 400, matching what the audit endpoint already did. No clamping, because the log buffer caps at its own maximum regardless of what is asked for, so an oversized value cannot cause unbounded work the way the audit limit could against a growing collection. -
An empty
tailorsince(?tail=) was read as absent rather than invalid, on all three log endpoints. Every one of them guarded withif (!value) return undefined, and an empty string is falsy along withnullandundefined, so a supplied-but-empty value skipped the 400 above it and was silently forwarded downstream asundefinedinstead of being rejected as malformed input. The controller's log and agent endpoints and the agent-mode API's own log endpoint all now treat only a genuinely missing parameter as absent; an empty string proceeds to the integer-shape check like any other supplied value and is rejected. -
A
limitoroffseton the audit endpoint with a numeric prefix, such as?limit=25logs, validated as the leading digits instead of being rejected.Number.parseIntstops at the first non-digit rather than requiring the whole string to be a clean integer, the same bug already fixed on the log and agent endpoints'tail/since. Audit's contract falls back to its default instead of erroring on a bad value, so that stays: an unparseablelimit/offsetnow behaves the same as an absent one and falls back to the default, rather than switching audit over to a 400 like the other three params. A value that overflowsNumber.MAX_SAFE_INTEGERgets the same treatment, closing a related precision-loss gap where a huge digit string used to silently clamp to 200. -
A duplicate Portwing agent key was reported as a server error. The conflict now returns 409 with the conflict message, carried by a typed error rather than by matching on the message text, which is how the update path already does it.
-
The Dutch curl healthcheck banner named the wrong thing. Its title rendered the CLI tool
curlaskrulstatus, which in Dutch is a decorative curl. -
Drydock offered older LinuxServer builds as updates.
v8.9.0-ls101was reported as having an update available tov8.9.0-ls99, and the same for0.5.0b3.dev101-ls250down to0.5.0b3.dev99-ls235and4.0.19.2979-ls322down to4.0.9.2244-ls257. Three separate mechanisms, all landing in the same comparator. The-lsNNNcases were spec-correct semver behaving wrongly for the shape: an alphanumeric prerelease identifier compares lexically, so"ls101" < "ls99"on the first differing character. The four-component case was worse and not truncation:semver.clean('4.0.19.2979-ls322', {loose: true})mis-splits the19and returns the corrupted4.0.1-9.2979-ls322, while the same call on4.0.9.2244-ls257returns null and falls through tocoerce()for a clean4.0.9, so the two sides were never compared through the same parse path at all. Candidates whose version cores tie are now compared on the numeric counters embedded in an otherwise identical suffix template, generalised to any trailing counter rather than special-cased to LinuxServer, and a pair whose ordering cannot be established safely is declined rather than guessed. Note this was visible on default settings too:DD_WATCHER_DOCKER_TAG_FAMILY=loosewas needed for the downgrade to be offered as actionable, but the informational "newer version available" insight shared the same admission logic and showed the older tag without it. (#918) -
The action-policy badge and its tooltips rendered in English in every locale. The
Auto/Manual/Blockedbadge and the three state tooltips behind it, on both the update-status panel and the per-trigger rows, were added to the English catalog only, so eleven strings fell back to English for every non-English user from the release that introduced them. Translated in all 16 locales. The key-parity gate now fails on a key that exists in English and not in a locale, which is what should have caught this: it previously tolerated the gap on the grounds that Crowdin would fill it after the push, and what Crowdin actually fills it with is verbatim English. -
The weekly Crowdin sync no longer reverts the six translated READMEs to English.
README.mdwas registered as a Crowdin source, so every sync round-tripped the translated copies through the project. Turning off content segmentation in #804 to fix an earlier corruption renumbered every segment, which orphaned the stored translations from the strings they belonged to, and Crowdin exports source text for anything it cannot match. The next sync opened as a pull request deleting 1392 lines of translated README, caught only byreadme-translations.test.ts. Crowdin no longer mapsREADME.mdat all: the translated READMEs are hand-authored in-repo and asserted phrase by phrase at every cut, and nobody was translating them in Crowdin. The UI locale catalogs are unaffected, since segmentation never applied to JSON. (#919) -
Restoring the maturity-policy stash at startup no longer ignores the size cap, or evicts the wrong entry afterwards. The durable
updatePolicyRetentionCacheadded in 1.7.0-rc.2 restored every non-expired persisted record straight into memory without checkingDD_UPDATE_POLICY_RETENTION_CACHE_MAX_ENTRIES, so lowering that limit between restarts came back over it and the extra entries stayed live until the next stash trimmed them. Restoring also used the store's document order, which has nothing to do with stash age, and the eviction that runs when the cap is hit reads that order as its LRU, so the first eviction after any restart could drop the newest entry and keep the oldest. Startup now restores oldest first and enforces the cap against both the in-memory cache and the persisted records. (#565)