This is the multi-page printable view of this section. .
PGSTY SILO Blog
- 1: Posts
-
2: Release Notes
- 2.1: Silo Console 2.2.0 Release Notes
- 2.2: Silo Console 2.1.0 Released
- 2.3: Silo Console 2.0.0 Released
- 2.4: silo-pkg 3.12.0 Released
- 2.5: Silo Pkg 3.11.0 Released
- 2.6: mcli 20260806 Released
- 2.7: Silo 20260806 Released
- 2.8: Silo 20260804 Released
- 2.9: mcli 20260804 Released
- 2.10: Silo 20260618 Released
- 2.11: Silo 20260417 Released
- 2.12: Silo 20260325 Released
- 2.13: Silo 20260321 Released
- 2.14: Silo 20260314 Released
- 2.15: Silo 20260214 Released
- 2.16: Silo 20251203 Released
-
3: SILO Security Chronicle
- 3.1: CVE-2025-62506: Session-Policy Privilege Escalation
- 3.2: CVE-2026-32285: The jsonparser Advisory That Required No Patch
- 3.3: CVE-2026-33322: OIDC JWT Algorithm Confusion
- 3.4: CVE-2026-33419: LDAP STS Enumeration and the Throttling Chain
- 3.5: CVE-2026-34204: Replication Metadata Injection
- 3.6: CVE-2026-39414: Oversized S3 Select Records and a SIMD Bypass
- 3.7: CVE-2026-40344: Snowball Auto-Extract Authentication Bypass
- 3.8: CVE-2026-41145: Unsigned-Trailer Query Authentication Bypass
- 3.9: CVE-2026-42600: ReadMultiple Storage-REST Path Traversal
- 3.10: Internode Path Containment Audit: Paying Off What CVE-2026-42600 Left Owing
- 3.11: The Parser Knew, the Schema Didn't: Config Keys That Could Take Every Notification Down
- 3.12: Object Grant, Bucket Reach: When 'bucket/*' Could Rewrite the Bucket Itself
- 3.13: Absent Is Not Empty: A Blank versionid and the Fail-Open It Invites
- 3.14: Three Headers, One Lie: Making the Client Source Address Mean Something
- 3.15: Sorted Is Not Increasing: How One Duplicate Part Number Doubled an Object
-
4: Design Records
- 4.1: Conditional DELETE: Why the Condition Must Be Evaluated Once
- 4.2: DSN-Only Database Notifications: A Compatibility Boundary for #53
- 4.3: Preview Text, Never Execute It: SILO Console Text Preview PRD
- 4.4: One Endpoint, Two Privileges: Separating User and Group Status
- 4.5: Config Environment Files Are Not Shell Scripts
- 4.6: Two SSE-C Keys, One CopyObject Response
- 4.7: Why CompleteMultipartUpload Must Return ChecksumType: Review of PR #57
- 4.8: When the Total Is Unknown: Folder Download Progress
- 4.9: A ListObjects Shortcut Must Not Turn a Missing Bucket into an Empty One
- 4.10: Optional Checksums, Mandatory Failure: Repairing UploadPart and UploadPartCopy Compatibility
- 4.11: BadDigest, InvalidRequest, and the CompleteMultipartUpload Checksum Contract
- 4.12: Per-Bucket CORS: Making Deletes and Recovery Converge
- 4.13: Per-Bucket CORS Wire Contract: Strict XML, Checksums, and Browser Responses
1 - Posts
Essays and analysis about MinIO, S3-compatible object storage, and the SILO community fork.
1.1 - MinIO Fork, Promise Kept
Two months ago in “MinIO is Dead, Long Live MinIO,” I promised I’d keep the MinIO fork patched. The recurring objection on HN is fair: can one person actually maintain something like this? The real answer isn’t clicking fork. It’s what happens when CVEs start landing.
Between April 15 and 17, pgsty/minio shipped RELEASE.2026-04-17,
closing four CVEs and a handful of related vulnerabilities disclosed in the same window.
The scope I committed to originally was narrow: no new features, keep the supply chain running, handle reproducible bugs and security issues as they come in. This release is what it looks like when that promise gets tested.
What happened upstream
In December 2025, MinIO moved the open-source repo to maintenance mode. The README said security fixes would be “evaluated case by case.” In February 2026, the repository was archived and the landing page became “this repository is no longer maintained.”
The SECURITY.md in that same archived repo still says: “we will always provide security updates for the latest release.”
Over the past month, four high-severity and two medium-severity vulnerabilities have been disclosed against the final open-source release.
It’s been 184 days since the last upstream release. Vulnerabilities get disclosed; fixes ship only in the commercial build. The guidance for OSS users is a single line: upgrade to AIStor.
AIStor starts around $100k/year for 400 TiB — roughly S3 pricing, for software you install and operate yourself.
It’s a clean arrangement: archive the repo so there’s no obligation to patch, keep publishing CVE advisories for visibility, and route everyone who reads them toward the commercial product.
Someone still has to patch the old one.
What this release fixes
Full write-ups, CVSS arithmetic, and PoCs are in the release notes. The short version:
- CVE-2026-33322 (OIDC JWT algorithm confusion, CVSS 9.8): under certain IdP configurations, an attacker who knows the OIDC ClientSecret can mint a token claiming any identity — including
consoleAdmin— and MinIO will accept it. Vulnerable window: November 2022 through March 2026. About three and a half years. - CVE-2026-33419 (LDAP STS enumeration and brute-force): the login endpoint leaks which usernames are real, and there’s no rate limiting on the subsequent password guessing. End of the chain is an STS credential.
- CVE-2026-34204 (replication-header metadata injection): a regular PUT or COPY with certain
X-Minio-Replication-*headers can write an object into a permanently unreadable state. The data is still on disk; you just can’t read it back out. - CVE-2026-39414 (S3 Select memory exhaustion): one request, one OOM.
- GHSA-hv4r-mvr4-25vw / GHSA-9c4q-hq6p-c237: two signature-verification bypasses on the unsigned-trailer path. Anonymous or forged-signature requests can successfully write objects on certain routes.
Plus the usual dependency cleanup from go-jose, go.opentelemetry.io, and the Go 1.26.2 upgrade itself — about twenty security items in total counting transitive dependencies.

How it got fixed
I said in the earlier post that I’d rely on AI coding agents, and that’s how this round went. My role was closer to “review and decide” than “write code.”
Per-issue flow, roughly:
- Codex drafts first. Given the CVE description and relevant code paths, it produces an initial patch.
- Claude Code reviews adversarially. Picks holes from the attacker’s side.
- Back to Codex. If it agrees with Claude Code’s critique, it reworks. If not, it has to write out why. No silent overrides.
- Another round of review by Claude Code, with both sides’ reasoning on the table. Iterate until they converge.
- Tests. Codex proposes cases, Claude Code adds more, Codex runs them, Claude Code reviews the results.
- I decide. Read the diff, run the tests, merge or send it back with comments.
I didn’t write any of the code in this round. My job was to define the problem, set constraints, pick between approaches, read diffs, run tests, and merge. The GitHub log shows Vonng, Codex, and Claude Code as co-authors — that’s just who did the work.

A few things I noticed about how this runs in practice.
Two heterogeneous agents in opposition catch more than one agent alone. A single agent patching a security bug tends toward confident-sounding fixes that quietly miss a boundary condition. Having a second agent argue against the first filters out most of those.
It forces the tradeoffs into writing. When two implementations diverge, someone has to say why A over B. That exchange is the thing I can actually act on as the person deciding what to merge.
Real maintenance is patch-on-patch, not one-shot. The LDAP STS fix is a good example. The first version landed, and then we realized: successful requests shouldn’t count against the rate limit; X-Forwarded-For shouldn’t be trusted by default; the limiter should key on source IP plus normalized username, not just one. Three follow-up commits before it settled. Iterating through that by hand would have cost a lot more time.
Why this fork exists
Because I use MinIO myself.
MinIO is a production dependency for Pigsty. I need working binaries, a complete console, packages that keep shipping, and someone actually handling CVEs. That keeps the scope narrow. No new features, no turning the repo into a playground. Compatibility, supply chain, fixes when they’re needed.
— Chainguard also ships MinIO container images that track upstream’s post-archive commits, a useful option if you use their images. This fork is a different shape: source tree, RPM/DEB packages, restored console, and doesn’t depend on upstream continuing to push patches somewhere.
The fork is at about 1,300 stars on GitHub and 50,000+ pulls on Docker Hub now. Not remarkable numbers, but enough to tell me I’m not the only one who needed this fork to keep shipping.

If you’re already running OSS MinIO, migration is cheap:
- Docker: swap
minio/minioforpgsty/minio. - RPM / DEB: on GitHub Releases, or via
pig. - Source: pgsty/minio
- Docs: silo.pgsty.com
You don’t need to replace anything around it or relearn the API. In most cases, you’re just pointing a compatible binary at the same deployment. If you want a full HA production setup, Pigsty ships one for free.
Something I use broke; I’m fixing it.
What’s different in 2026 is the cost of “I’m fixing it.” With two coding agents and someone to referee between them, the maintenance load of a mid-sized Go codebase is tractable for one person in a way it wasn’t a year or two ago. That’s about it — not a grand theory about open-source resilience, just the current operating point.
If you’re running OSS MinIO, the migration is cheap and the patches are current. If another CVE drops, I’ll still be here.
1.2 - MinIO Is Dead, Long Live MinIO
MinIO’s open-source repo has been officially archived. No more maintenance. End of an era — but open source doesn’t die that easily.
I created a MinIO fork, restored the admin console, rebuilt the binary distribution pipeline, and brought it back to life.
If you’re running MinIO, swap minio/minio for pgsty/minio.
Everything else stays the same. (CVE fixed, and the console GUI is back)
The Death Certificate
On December 3, 2025, MinIO announced “maintenance mode” on GitHub. I wrote about it in MinIO Is Dead.
On February 12, 2026, MinIO updated the repo status from “maintenance mode” to “no longer maintained”, then officially archived the repository. Read-only. No PRs, no issues, no contributions accepted. A project with 60k stars and over a billion Docker pulls became a digital tombstone.

If December was the clinical death, this February commit was the death certificate.
Today (Feb 14), a widely circulated article titled How MinIO went from open source darling to cautionary tale laid out the full timeline.

Percona founder Peter Zaitsev also raised concerns about open-source infrastructure sustainability on LinkedIn. The consensus in the international community is clear:
MinIO is done.
Looking back at the timeline over the past years, this wasn’t a sudden death. It was a slow, deliberate wind-down:
| Date | Event | Nature |
|---|---|---|
| 2021-05 | Apache 2.0 → AGPL v3 | License change |
| 2022-07 | Legal action against Nutanix | License enforcement |
| 2023-03 | Legal action against Weka | License enforcement |
| 2025-05 | Admin console removed from CE | Feature restriction |
| 2025-10 | Binary/Docker distribution stopped | Supply chain cut |
| 2025-12 | Maintenance mode announced | End-of-life signal |
| 2026-02 | Repo archived, no longer maintained | End of project |
A company that raised $126M at a billion-dollar valuation spent five years methodically dismantling the open-source ecosystem it built.
But Open Source Endures
Normally this is where the story ends — a collective sigh, and everyone moves on.
But I want to tell a different story. Not an obituary — a resurrection.
MinIO Inc. can archive a repo, but they can’t archive the rights that the AGPL grants to the community.
Ironically, AGPL was MinIO’s own choice. They switched from Apache 2.0 to AGPL to use it as leverage in their disputes with Nutanix and Weka — keeping the “open source” label while adding enforcement teeth. But open-source licenses cut both ways — the same license now guarantees the community’s right to fork.
Once code is released under AGPL, the license is irrevocable. You can set a repo to read-only, but you can’t claw back a granted license. That’s the beauty of open-source licensing by design: a company can abandon a project, but it can’t take the code with it.
So — MinIO is dead, but MinIO can live again.
That said, forking is the easy part. Anyone can click the Fork button. The real question isn’t “can we fork it” but “can someone actually maintain it as a production component?”
Why would I do that?
I didn’t set out to take this on. But after MinIO entered maintenance mode, I waited a couple of weeks for someone in the community to step up.
But I didn’t find one. So I did it myself.
Some background: I maintain Pigsty — a batteries-included PostgreSQL distribution with 460+ extensions, cross-built for 14 Linux distros. I also maintain build pipelines for 290 PG extensions, several PG forks, and dozens of Go Projects (Victoria, Prometheus, etc.) packaging across all major platforms. Adding one more to the pipeline was a piece of cake.
I’m not new to MinIO either. Back in 2018, we ran an internal MinIO fork at TanTan (back when it was still Apache 2.0), managing ~25 PB of data — one of the earliest and largest MinIO deployments in China at the time.
More importantly, MinIO is an optional module in Pigsty. Many users run it as the default backup repository for PostgreSQL in production. We did consider several alternatives, but none were a drop-in replacement for MinIO-based workflows.
We use MinIO ourselves, so keeping the supply chain alive was not optional — it had to be done. As early as December 2025, when MinIO announced maintenance mode, I had already built CVE-patched binaries and switched to them.
What We’ve Done
As of today, three things.
1. Restored the Admin Console
This was the change that frustrated the community the most.
In May 2025, MinIO stripped the full admin console from the community edition, leaving behind a bare-bones object browser. User management, bucket policies, access control, lifecycle management — all gone overnight. Want them back? Pay for the enterprise edition. (~$100,000)
We brought it back.

The ironic part: this didn’t even require reverse engineering.
You just revert the minio/console submodule to the previous version.
They swapped a dependency version to replace the full console with a stripped-down one. The code was always there.
We put it back.
2. Rebuilt Binary Distribution
In October 2025, MinIO stopped distributing pre-built binaries and Docker images,
leaving only source code. “Use go install to build it yourself” — that was their answer.
For the vast majority of users, the value of open-source software isn’t just a copy of the source — supply chain stability is what matters. You need a stable artifact you can put in a Dockerfile, an Ansible playbook, or a CI/CD pipeline — not a requirement to install a Go compiler before every deployment.
We rebuilt the distribution:
- Docker Images
pgsty/miniois live on Docker Hub.docker pull pgsty/minioand you’re good.- RPM / DEB Packages
- Built for major Linux distributions, matching the original package specs.
- CI/CD Pipeline
- Fully automated build workflows on GitHub, ensuring ongoing supply chain stability.
If you’re using Docker, just swap minio/minio for pgsty/minio.
For native Linux installs, grab RPM/DEB packages from the GitHub Release page. You can also use pig (the PG extension package manager) for easy installation, or configure the pigsty-infra APT/DNF repo to install from it:
Just works as usual.
3. Restored Community Edition Docs
MinIO’s official documentation was also at risk — links had started redirecting to their commercial product, AIStor.
We forked minio/docs, fixed broken links, restored removed console documentation, and deployed it as the SILO documentation site.
The docs use the same CC Attribution 4.0 license as the original, with necessary maintenance.

Commitments
Some things worth stating up front to set expectations.
No New Features — Just Supply Chain Continuity
MinIO as an S3-compatible object store is already feature-complete. It’s a finished software. It doesn’t need more bells and whistles — it needs a stable, reliable, continuously available build. (I already have PostgreSQL for these, so I don’t need something like S3 table or S3 vector. A stable S3 core is all I need)
What we’re doing: making sure you can get a working, complete MinIO binary, with the admin console included and CVE fixed. RPM, DEB, Docker images — built automatically via CI/CD, drop-in compatible with your existing minio. We keep the existing minio naming and behavior where legally and technically feasible.
This Is a Production Build, Not an Archive
We run these builds ourselves and have been dogfooding them in production for three months. If something breaks, we detect it early and patch it quickly.
I build this primarily for Pigsty and our own usage, but I hope it helps others too.
I’m willing to Track CVEs and Fix Bugs
If you run into issues, feel free to report them at pgsty/minio.
I’ll do my best to fix these — but please don’t treat this as a commercial SLA.
Given that AI coding tools have made bug fixing dramatically cheaper, and that we’re explicitly not adding any new features, I believe the maintenance workload is manageable. (how often do you see one?)
Trademark Is Tricky, But We’ll Cross That Bridge When We Come to It
Disclaimer
Trademark Notice: MinIO® is a registered trademark of MinIO, Inc. This project (pgsty/minio) is an independently maintained community fork under the AGPL license. It has no affiliation with, endorsement by, or connection to MinIO, Inc. Use of “MinIO” in this post refers solely to the open-source software project itself and implies no commercial association.
AGPLv3 gives us clear rights to fork and distribute, but trademark law is a separate domain. We’ve marked this clearly everywhere as an independent community-maintained build.
If MinIO Inc. raises trademark concerns, we’ll cooperate and rename (probably something like silo or stow).
Until then, we think descriptive use of the original name in an AGPL fork is reasonable — and renaming all the minio references doesn’t serve users.
AI Changed the Game
You might ask: can one person really maintain this?
It’s 2026. Things are different now.
AI coding tools are changing the economics of open-source maintenance.
With tools like Claude Code & Codex, the cost of locating and fixing bugs in a complex Go project has dropped by more than an order of magnitude. What used to require a dedicated team to maintain a complex infra project can now be handled by one experienced engineer with an AI copilot.
Maintaining a MinIO build without adding new features is a manageable task. The key requirement is testing and validation. and we already have that scenario, which lets us verify compatibility, reliability, and security in practice.
Consider: Elon cut X/Twitter’s engineering team down to ~30 people and the system still runs. Maintaining a MinIO fork without new features is considerably less daunting
Just Fork It
MinIO Inc. can archive a GitHub repo, but they can’t archive the demand behind 60k stars, or the dependency graph behind a billion Docker pulls. That demand doesn’t disappear — it just finds its way out.
HashiCorp’s Terraform got forked into OpenTofu, and it’s doing fine. MinIO’s situation is actually more favorable —
AGPL is more permissive for forks than BSL, with no legal gray area for community forks.
A company can abandon a project, but open-source licenses are specifically designed so the code can’t die.
Fork is the most powerful spell in open source. When a company decides to shut the door, the community only needs two words:
Fork it.
Reference
- MinIO Is Dead
- MinIO Is Dead, Are There Alternatives?
- From AGPL to Apache: Reflections on Pigsty’s License Change
- MinIO: Promise made, Promise kept
1.3 - MinIO Is Dead. Which Next?
MinIO announced maintenance mode two days ago. I ranted in “MinIO Is Dead” and immediately got flooded with “so what now?”
The usual suspects: Ceph, RustFS, SeaweedFS, Garage. I packaged all of them for Linux (RPM/DEB) and ran them through the grinder.
Short version: there’s no perfect substitute. Ceph is powerful but overkill; SeaweedFS rocks tiny files but needs an external metadata DB; Garage is cute but too barebones; RustFS targets the MinIO niche but is still alpha.
Quick scan of the field
MinIO is the open-source S3 clone. If all you need is basic object CRUD, any S3-compatible store works. But parity with MinIO means more than APIs—it’s about reliability, operability, tooling, documentation, SOPs. Replacing it cleanly is hard.
Ignoring commercial clouds, here’s the OSS menu:
- Ceph – arguably the best choice for enterprises, but brutally complex. Most folks don’t need block + file + object in one, and it requires extras like Podmon. MinIO’s single binary spoiled us.
- SeaweedFS – optimized for oceans of small files; O(1) disk seeks make it absurdly fast there. But it relies on an external metadata store. If you want a general-purpose object store, that dependency is annoying.
- Garage – built by Deuxfleurs with NGI funding. Delightfully light (10 MB), great for self-hosters and edge nodes. But S3 compatibility is thin: no versioning, no cross-region replication, no IAM. Enterprises will laugh.
- RustFS – the only project explicitly chasing “drop-in MinIO,” but it’s still alpha.
RustFS vs. MinIO
RustFS looked the most promising, so I wired it into Pigsty as a MinIO replacement. Most logic carried over, but a few differences popped up:
- Certificates must follow specific naming rules.
- Health checks differ from MinIO’s endpoints.
mc admindoesn’t work; you can’t push fine-grained IAM policies. That’s a deal-breaker for many teams.
It ran, but I’m not shipping alpha software into production, so I shelved the branch. I’ll revisit when RustFS hits GA.
Will RustFS repeat MinIO’s mistakes?
RustFS has potential, but I worry it’ll retrace MinIO’s path. I asked the AI big three (GPT‑5 Pro, Claude 4 Opus, Gemini 3 Pro) to audit the project. Gemini leveled some serious accusations; Claude corroborated.
The red flags match MinIO’s history: Apache 2.0 license + copyright assignment CLA + single commercial gatekeeper. With that risk profile, I’m downgrading RustFS from “optimistic” to “cautious wait-and-see.”
So what now?
Pigsty bundles MinIO as an optional module for PostgreSQL backups or as an on-prem S3 for apps like Supabase. After surveying the alternatives, I’m not eager to swap it out. I might add a pgBackRest-native backup server option, but ripping out MinIO today feels premature.
Best plan: stay on the latest MinIO release, lock the version, isolate it on the network, and wait a few months. Maybe the community forks it; maybe RustFS matures. Adjust when reality changes.
RustFS still has a golden window to seize MinIO’s niche with a safer, community-friendly fork. That window is measured in months, not years.
If you stick with MinIO
Use the latest build, not the April 22, 2025 edition with the GUI. There’s a serious CVE in the interim:
- CVE-2025-62506 – privilege escalation via session-policy bypass (HIGH). Low-privilege users can mint new accounts and escalate.
In a locked-down intranet the risk is manageable, but you still want the fix, which landed in the 2025‑10‑15 release. MinIO pulled the prebuilt binaries starting with that version, offering source only. Annoying, but it’s Go—go build and you’re done. I forked MinIO, ran their packager, and produced RPM/DEBs for 2025‑12‑03 so I’m not deploying vulnerable bits: https://github.com/pgsty/minio

Security patches still need humans. MinIO claims they’ll fix critical issues, but if the community wants a maintained fork, now’s the moment. Start from 2025‑04‑22, cherry-pick critical bug/security fixes, and keep a community LTS alive.
MinIO is “done” software. It doesn’t need the latest S3 gimmick (Vector/Table); it needs steady bugfixes. That’s perfect for a community branch. Plenty of storage vendors rely on MinIO; maintaining a fork beats writing a new object store from scratch.
2026-02-14 Update: MinIO’s official repo has been fully archived and is no longer maintained. Besides, I’ve personally maintained an oss fork of minio:
pgsty/minio/ Docs: https://silo.pgsty.com. Which based on the last upstream version 2025-12-03 with restored console capabilities.
1.4 - MinIO is Dead
December 3, 2025 was a day to mark in open-source software history. MinIO’s team updated the project status on GitHub, announcing the MinIO open-source project was entering “maintenance mode.” This basically declared the death of MinIO as an open-source project.
MinIO the company has finally completed its transformation from a dragon-slaying hero into the very dragon it once sought to slay.

From Dragon-Slayer to Dragon
Democratization Era (2014–2019): The Apache of Object Storage
MinIO was founded in 2014 with a highly idealistic vision – to be “the Apache of object storage.” In an era dominated by AWS S3, MinIO’s ultra-lightweight design (a single static binary) and 100% S3 API compatibility quickly won developers’ hearts.
During this phase, MinIO was licensed under the liberal Apache 2.0 license, encouraging developers to integrate it into all kinds of applications. Its core pitch: “turn any hardware into AWS S3.” This open strategy was wildly successful. MinIO claimed its Docker image had been pulled over 1 billion times, making it the world’s most widely deployed object storage service. At this point, MinIO was a darling of the cloud-native stack – the default storage backend in many Kubernetes setups.
License Weaponization (2019–2025): The AGPL War
The first major crack in community relations appeared around 2019–2021. MinIO announced it was changing its core license from Apache 2.0 to GNU AGPLv3.
The official explanation was that this move aimed to prevent cloud providers (like AWS, Azure) from “freeloading” the code and repackaging it as proprietary services — a common defensive tactic in open source. During this period, MinIO shifted from being a community guardian to an aggressive defender of its IP. In 2022, MinIO publicly accused Nutanix Objects of violating its license and revoked Nutanix’s right to use MinIO; in 2023, MinIO sued high-performance filesystem vendor Weka on similar grounds. These legal actions, though legally contentious, sent a clear signal: MinIO no longer welcomed commercial use without paying up. This set the legal and psychological stage for the full lockdown that would come in 2025.
Control Plane Neutered (May 2025)
In May 2025, MinIO decided to strip the MinIO Console out of the community edition. The console was a critical GUI for bucket management, IAM, monitoring, and audit logging. After this removal, the open-source MinIO was left with only a basic “object browser” GUI – essentially just a file viewer/downloader.
Meanwhile, key admin features like policy management, site replication configuration, and lifecycle management were moved entirely into the commercial enterprise edition. This change downgraded the open-source MinIO from a full-featured storage management system into a mere data-plane component, robbing it of the control-plane capabilities needed to run as a standalone product in production.
Cutting Off Binary Distribution (Oct 2025)
On October 15, 2025 – right as a critical security vulnerability (CVE-2025-10-15T17-29-55Z / GHSA-jjjj-jwhf-8rgr) was disclosed – MinIO stopped publishing updated Docker images to Docker Hub and Quay.io. The timing of this move was highly strategic. By cutting off binaries during a major security incident, MinIO effectively used security as a bargaining chip.
This decision directly broke the automated deployment pipelines for countless users. Helm charts, Ansible playbooks, and Terraform scripts expecting minio/minio (or Bitnami’s minio) image suddenly failed to find updates.
Auto-scaling groups trying to pull new nodes hung due to missing images. For teams without a Go build environment or an internal container registry, MinIO instantly became unusable.
Maintenance Mode (Dec 2025)
On December 3, 2025, MinIO, Inc. officially updated its channels and GitHub repo to announce that the open-source project is now in “maintenance mode.” The README stated that there will be no further feature additions or improvements, issues and PRs will no longer be reviewed, and even critical security fixes would be provided “as appropriate.” No more RPM/DEB packages or Docker images will be released. Essentially, anyone needing updates or support is advised to switch to the commercial AIStor product.

Technical Impact: Damage to the Open-Source Ecosystem
MinIO’s move to maintenance mode dealt an immediate and far-reaching blow to many tech stacks.
Broken CI/CD Pipelines and an Automation Crisis
Thousands of Helm charts, Ansible playbooks, and Terraform scripts depend on the minio/minio (or Bitnami’s minio) container image.
With official images no longer published, third-party packagers like Bitnami — who can’t get a stable upstream release — also had to stop updates.
- Cascade effect: Deployments in fresh environments started failing outright. Auto-scaling groups, upon launching new instances, would hang or error out when the MinIO image couldn’t be pulled.
- Cost of fixes: Companies now have to rewrite their deployment scripts to point to a self-hosted image, and set up internal build pipelines to compile and package MinIO from source.
Security Vacuum: CVE Patches Go Private
The most lethal consequence of halting binary distribution is delayed security patches. In the October 2025 incident, for example, MinIO effectively withheld the patched binaries for the vulnerability.
- Risk exposure: Companies without dedicated security teams are forced to keep running older, vulnerable versions with known critical flaws.
- Compliance nightmare: For organizations under PCI-DSS, HIPAA, SOC2, etc., not being able to obtain vendor-signed security updates is a compliance disaster. Lacking official patches, they technically fall out of compliance.
Exponentially Higher Ops Complexity
Removing the UI wasn’t just a hit to user experience – it increased operational burden.
Tasks that used to be a few clicks in the Console (configuring bucket policies, setting user permissions) now require ops engineers to master the mc CLI or hand-craft complex JSON policy docs.
This raises the skill floor and makes MinIO far less friendly as a lightweight internal tool.
Underlying Reasons: Pressure from Capital and Commercialization
The driving force behind MinIO’s decisions is the logic of venture capital. By 2025, MinIO had raised a total of $126 million in funding. The most significant was a $103 million Series B in January 2022 led by Intel Capital, SoftBank Vision Fund II, and General Catalyst, which crowned MinIO a unicorn (valued over $1 billion).
In VC terms, a $1B valuation means the company must show a clear path to IPO — typically demanding $100M+ in Annual Recurring Revenue (ARR) and rapid growth. In Feb 2025, MinIO announced its ARR had grown 149% over the past two years businesswire.com. Impressive growth, but to live up to a sky-high valuation, organic conversion alone wasn’t enough.
Cutting off the free open-source offering is the most direct way to force a huge user base into paid customers.
In 2025, MinIO underwent a full rebrand and launched “MinIO AIStor,” styling itself as “the data backbone for enterprise AI.” Management recognized that general-purpose object storage (for backups, file servers, etc.) was a red-ocean market with thin margins, whereas generative AI’s appetite for high-throughput data (the exascale AI era) promised the next big surge. By tuning its product for AI workloads and focusing on Fortune 500 enterprises linkedin.com, MinIO essentially decided to cut loose its low-value open-source user base. The move to maintenance mode signaled MinIO’s official pivot from a broad open-source project into a vertical, high-end AI software vendor.
MinIO isn’t a garage hobby project by a few geeks anymore; it’s a company that took $126M in VC and is valued at over $1B. Backed by Intel Capital and SoftBank, once you take that money, your boss is no longer the users — it’s the investors. And what do investors want? ARR, growth, IPO. You tell them, “We have a billion Docker pulls!” and they’ll ask, “How many dimes did those pulls pay us?”
The reality is brutal. To the VCs, those small businesses and individual devs using free MinIO are low-value assets. They open issues and ask for support — consuming expensive engineer time, bandwidth, and servers — yet will never convert to paying customers. MinIO’s leadership knows their real cash cows are the Fortune 500 firms doing generative AI. The ones training GPT models or running self-driving pipelines need AIStor, ultra-high performance, and 24/7 enterprise SLAs.
So flipping the project into “maintenance mode” is essentially an asset carve-out. MinIO is cutting away the “dead weight” (free users) and concentrating on the milkable “cash cows” (enterprise AI clients). In business strategy this is called focus. To the investors, it’s being responsible. But from the perspective of open source, it’s simply betrayal.
Personal Reflections
I started using MinIO around 2018 (back when it was Apache-licensed). We built a few multi-petabyte object storage clusters for videos, images, backups — probably one of the largest MinIO deployments in China at the time. I wrote deployment/monitoring playbooks for MinIO (still open-sourced in Pigsty).
As an open-source startup founder, I can understand the motivation behind these moves. But as an open-source contributor and user — I also know many folks right now have one phrase in their minds: “I have never seen such shamelessness.”
An open-source license isn’t a shackle, but it is a social contract. Developers contribute code, users contribute testing, feedback, and reputation; together, they make a project successful. MinIO enjoyed a decade of community goodwill and parlayed the bragging rights of “#1 in global downloads” into venture funding. Then it turned around and told the very users who propped it up: “You free-riders, get lost.” This kind of move breaks the fundamental trust that open source is built on.
This “bait and switch” tactic is even more nauseating than a crypto rug pull. A rug pull only takes your money — MinIO is pulling the rug out from under the tech stacks of thousands of companies. Adopting a technology isn’t just picking up a binary; it’s buying into an ecosystem and a design philosophy. They got everyone onboard, let the switching costs pile up sky-high, and then suddenly kicked away the ladder. In fact, as open-source expert Tison thoroughly discussed in his article The Bait-and-Switch Open-Source Strategy, the core issue with this model is deception.
MinIO betrayed the community, so the community may abandon it as well. Alternatives like Garage, SeaweedFS, or the new RustFS are ready to step in.
If I have to sum up my feelings, I’d borrow a line from The Hitchhiker’s Guide to the Galaxy:
—— “So long, and thanks for all the fish.”
2026-02-14 Update: MinIO’s official repo has been fully archived and is no longer maintained. Besides, I’ve personally maintained an oss fork of minio:
pgsty/minio/ Docs: https://silo.pgsty.com. Which based on the last upstream version 2025-12-03 with restored console capabilities.
2 - Release Notes
Each published SILO version has its own page with the release date, major changes, security fixes, dependency updates, and related commits.
2.1 - Silo Console 2.2.0 Release Notes
Version: v2.2.0 · Release commit: 7dc4258a6 · Status: released · Repository: pgsty/silo-console
SILO Console 2.2.0 is a correctness-and-hardening release. It adds one new feature — a strictly bounded text preview for logs and structured text — and spends the rest of its budget making existing surfaces tell the truth: downloads that cannot silently ship a truncated archive, progress bars that cannot fabricate a percentage, permission gates that actually disable what they claim to disable, a user API that no longer entangles status changes with group membership, and database notification forms that emit exactly the connection string the server will store.
Underneath, the dependency stack moves to Go 1.27.0 and onto the maintained SILO forks: pgsty/silo-pkg 3.12.1 replaces upstream minio/pkg, pgsty/mc replaces upstream mc as a library, minio-go moves to 7.3.0, and the etcd client line moves to 3.7.1, closing CVE-2026-73500.
The final change set since v2.1.1 is 33 commits touching 176 source files (+8,794/−2,057 lines, not counting the regenerated embedded frontend assets).
Why 2.2.0 and not 2.1.2: the user-visible fixes alone would justify a patch release, but this cycle changes the provider of the shared policy/certificate implementation, crosses an etcd client minor boundary, splits a REST route, and deliberately changes download-failure semantics. Each of those deserves a minor-version line in the compatibility notes below rather than a silent patch.
Text Preview for Logs and Structured Text
The object browser can now preview .log, .txt, .json, and .xml objects as literal text. The implementation is deliberately paranoid, because “render arbitrary bucket content in the admin UI” is an XSS invitation:
- Bounded by construction. The request carries
Range: bytes=0-1048576; the response is streamed into a fixed buffer with a hard cap, andContent-Range/Content-Lengthare strictly validated against what was actually read. An object over 1 MiB reports too large — it is never partially rendered as if complete. - Text or nothing. The bytes must decode as strict UTF-8 (
TextDecoderwithfatal: true) and must not contain NUL; anything else reports not previewable instead of rendering mojibake or binary junk. Eligibility is an exact allowlist — the four extensions plus the precise MIME typestext/plain,application/json,application/xml,text/xml— and.html/.htm/.xhtmlare explicitly excluded even when their metadata claimstext/plain. - No active document. Content renders into a single DOM text node — never
innerHTML, never an iframe, never a JSON-to-HTML transform. A regression suite feeds it HTML, SVG, and XML payloads and asserts they stay inert text. - Race-proof. Switching quickly between objects invalidates in-flight previews by generation, so a slow response for the previous object cannot paint over the current one. Cancellation and retry are first-class states.
- Anonymous-friendly. Public/anonymous object pages get the same preview through the anonymous request path, without triggering any credentialed API call.
Alongside the new preview type, the existing preview plumbing was corrected: empty objects preview as empty instead of erroring, appended logs re-preview at their new length instead of the stale listed size, and metadata races when flipping between objects are gone. The preview and share dialogs now receive the object’s real size (92e8f4e65).
Downloads That Cannot Lie
The whole download path — progress reporting, single objects, folders, archives, byte ranges — was rebuilt around one principle: a download either completes correctly or fails visibly.
Honest progress
A missing, invalid, or contradictory total no longer becomes a fabricated percentage: the progress indicator stays indeterminate until the real total is known. Zero-byte objects are normalized deliberately instead of falling into the unknown-total path, and abort/cancel are terminal states — a late progress event cannot resurrect a cancelled download. Download requests settle exactly once, JSON error blobs are parsed safely, and generated object URLs are revoked.
Streaming folder downloads
Single-folder downloads now use the browser’s native streaming download path instead of accumulating the entire ZIP in JavaScript memory — a multi-GB folder no longer risks tab death. One consequence of the handoff model: the console’s transfer manager reports the folder download complete once it hands the stream to the browser (a toast says so), and the browser’s own download UI takes over from there — cancelling in the console after handoff does not stop the browser-side transfer. Multi-selection downloads still ride the existing POST response and therefore remain an in-memory Blob; changing that requires a separate API decision and is out of scope here.
ZIP integrity — a deliberate behavior change
This is the one change most likely to be noticed as “downloads broke”. In 2.1.1 and earlier, a folder/ZIP download that failed to read some objects silently skipped them and delivered an HTTP 200 archive missing files. In 2.2.0 any per-object failure — list, stat, read, entry creation, close, or copy — aborts the archive: a clean error if nothing was sent yet, an aborted connection mid-stream, so a truncated ZIP can never pass for a complete one.
The practical consequence: a user whose policy grants List on a prefix but GetObject on only a subset of it — a common IAM setup — previously received a partial archive; now the download fails at the first denied object. That was silent data omission, and 2.2.0 treats it as the bug. Download exactly what you can read, or scope the folder download to a readable prefix.
Byte ranges and status codes
- Malformed or unsatisfiable non-empty
Rangeheaders now return416withContent-Range: bytes */Ninstead of a 500. Range parsing is stricter than Go’s lenient default: signs, embedded whitespace,bytes=-0, and empty range elements are rejected. - A range request against a zero-byte object returns an empty 200 instead of a 500 — this was the empty-object preview bug.
- Failures from the lazy object
Statnow surface the real S3 status (403, 404, …) instead of a blanket 500. 206responses now setContent-Lengthbefore the header flush, so partial responses carry correct framing.- Object sizes are now always serialized — REST and WebSocket listings report
"size": 0for zero-byte objects instead of omitting the field, and the UI displays0 B. This is the ground truth the honest-progress work stands on.
Version history
S3’s valid null version ID stays visible instead of being dropped, version counting filters prefix matches to the exact object, and history is retained after bucket versioning is suspended or disabled.
Permission Gates That Actually Gate
Row actions were never disabled
Every screen with a data table passed its permission predicate through a prop name (disableButtonFunction) that the table component no longer reads — so view/edit/delete buttons rendered enabled regardless of permission, on every one of the 10 affected screens (Users, Groups, Policies, IDP, webhook settings, bucket access/replication/lifecycle panels). The predicates are now wired to the prop the component actually honors, and a source-guard test fails the build if the dead prop name ever reappears. Server-side authorization was never affected — the buttons produced errors when clicked — but the UI now communicates permissions instead of lying about them.
Independent service-account capabilities
Access-key management previously keyed several UI decisions off one combined permission check. 2.2.0 derives four independent capabilities — List, Create, Update, Remove — in one module and applies them consistently across the self-service Account screen and the admin user-details screen:
- the service-accounts tab shows when you can list, the create button when you can create, row selection and bulk delete only when you can remove, and the edit pencil only when you can update;
- View is now genuinely read-only: all fields disabled, no submit path, Enter is inert — previously “view” opened the editable dialog;
- the session’s advertised Create Access Key capability is computed correctly for request-scoped policies: a
Denyconditioned onsvc:DurationSeconds(an expiry restriction that can only be evaluated once a concrete request exists) keeps the capability visible — including with wildcard admin actions — while unconditional or login-time denies now properly hide it. The server remains the final authority at request time. The previous code kept the capability visible for any conditional deny, which over-advertised it. - an OIDC create/list/get/delete integration regression now runs through the UI’s explicit-credential endpoint, including the expected self-update denial.
Validate IAM policies before writes
Named-policy and service-account policy writes now reject malformed documents and bare S3 resource ARNs with a client error before making an Admin API request. Historical policy reads stay permissive for compatibility, but an incompatible stored policy must be corrected before it can be saved again. The strict parser and resource checks live in the console’s write path instead of importing fork-only policy APIs, preserving the advertised upstream minio/pkg v3.6.1 source-build floor.
Anonymous pages behave anonymously
Anonymous object-browser pages no longer issue protected Object Lock/retention requests that could only produce Access Denied noise, and they gained the language (文/A) and dark-mode controls.
User Status and Groups Are Separate Operations
Toggling a user’s enabled/disabled status and editing their group membership were one combined PUT /user/{name} call that required both payloads and — through the combined permission gate — demanded admin:EnableUser merely to edit groups. 2.2.0 splits them:
PUT /user/{name}/status(new) changes only the status, validates the value againstenabled|disabled, and rejects the signed-in user’s attempt to enable/disable themselves. Failed toggles no longer leave the switch out of sync — the UI reflects the server’s answer, not an optimistic flip.PUT /user/{name}/groups(existing) is now the only thing group editing calls, and no longer requiresadmin:EnableUser.PUT /user/{name}survives unchanged on the wire as a deprecated compatibility endpoint for existing API consumers; an unknown status there is now a 400 instead of a 500.
See Compatibility for the API-contract details, including a caveat for regenerated Swagger clients.
Database Notification Forms That Emit What They Mean
The PostgreSQL and MySQL event-destination forms were rewritten around a shared DSN parser/serializer:
- Structured fields and the raw connection string are one state: the raw string stays authoritative until a structured field is edited, at which point a canonical DSN is rebuilt — libpq keyword/value quoting for PostgreSQL, IPv6-bracket-aware
go-sql-driverformat for MySQL. Mode switches no longer mangle manually entered strings. - Generated previews mask credentials; the mask can never reach the API payload (the payload always uses the raw connection value).
- Saving requires both a connection string and a table, and cleared values actually propagate.
- The server now rejects DSNs its own configuration grammar would corrupt — embedded newlines, values that would parse as sibling config keys (
table=…inside a password), unbalanced quoting — with a 400 before anything is stored, and without echoing the submitted secret back. - Generic password and token fields across notification targets (Kafka, Redis, MQTT, NATS, webhooks) render as password inputs, including environment-overridden values.
Restart honesty
Configuration add, update, delete, and reset now honor the server’s actual restart-required answer instead of hardcoding “restart needed” (or worse, dropping it). The pending-restart flag is monotonic: once any operation requires a restart, later operations that don’t cannot clear it — only an actual service restart does.
Upload advisory
Browser uploads larger than 5 GiB get a non-blocking warning: the console uploads as one non-resumable request, and mcli with multipart upload is the right tool at that size.
Sessions, Metrics Auth, and i18n Hardening
- Sessions fail fast. An anonymous/empty session gets an immediate 401 instead of hanging on an empty-credential admin request. The console accepts the canonical
401and the legacy403invalid-session responses, and expired-session redirects are subpath-aware — a console served under/console/redirects within its base path. - Prometheus Basic auth works everywhere. The health check and the root-fallback probe now send Basic credentials (previously Bearer-only, so a Basic-auth Prometheus disabled every dashboard widget), Bearer tokens keep precedence, and every response body is drained so keep-alive connections are actually reused.
- Placeholder substitution is escape-proof by construction. Translated placeholder filling uses a one-pass literal formatter: values containing
$&,$1,$`, backticks, or braces stay literal, repeated placeholders all fill, and an AST source-guard test bans the unsafeString.replacepattern from ever returning.
Toolchain and Dependencies
Go 1.27 baseline
| Component | 2.1.1 | 2.2.0 |
|---|---|---|
go directive, build image, CI matrix |
1.26.5 / 1.26.x |
1.27.0 / 1.27.x |
golang.org/x/crypto |
v0.54.0 | v0.55.0 |
golang.org/x/net |
v0.57.0 | v0.58.0 |
golang.org/x/text |
v0.40.0 | v0.41.0 |
golang.org/x/mod |
v0.37.0 | v0.40.0 (closes CVE-2026-56864/-56865) |
golang.org/x/tools |
v0.47.0 | v0.49.0 |
The SILO forks
silo-pkg3.12.1 is the release dependency.pgsty/silo-pkgv3.12.1 was published on 2026-08-25 and the console pins it directly. It builds on the 3.12.0 release, which carries the policy resource-boundary hardening, condition-key lookup repair, LDAP and certificate-watcher fixes, and the Go 1.27 / etcd 3.7 baseline.- The
mclibrary moved to thepgsty/mcfork at a date-tagged pseudo-version. Import paths are unchanged. - The
requireline stays on upstreamv3.6.1deliberately. Go ignoresreplacedirectives in dependency modules, so a downstream module that requires this console resolves upstreamminio/pkg— and the SILOv3.12.1tag does not exist upstream. Requiring a real upstream tag keeps the console resolvable downstream; the replace applies the fork for the console’s own builds. The final CI verifies the public source-build surface against upstream v3.6.1. This is compile compatibility: a downstream build without its own top-level replace gets upstream behavior, not the fork’s SILO-specific IAM semantics. go-systemdis pinned back to v22.6.0: v22.7.0 usesCLOCK_MONOTONICon NetBSD, which doesn’t compile there; the pin holds until upstream ships the fix.
etcd 3.7.1 — client libraries only
All three etcd Go modules move together from 3.6.8 to 3.7.1, closing the TLS-listener denial of service GO-2026-6107 / CVE-2026-73500. etcd 3.7 removes legacy protobuf remnants and makes clientv3.New non-blocking — migrations that matter to consumers that construct clients or embed servers. The console does neither: its only etcd path is silo-pkg/quick operating on an already-created client with plain v3 Get/Put. This upgrades compiled client libraries only — it does not touch an operator’s etcd servers, cluster data, or deployment topology; a server upgrade to 3.7 still follows etcd’s own one-minor-at-a-time procedure.
Third-party maintenance stays conservative
The final release updates minio-go to v7.3.0 after a separate compatibility review, migrates its INI import path, and adds lifecycle-filter XML coverage. A console-side compatibility decoder accepts legacy AccountInfo tag payloads returned by older servers. Other accepted maintenance moves include jwx v2→v3 with httprc v3, go-openapi/swag/conv+typeutils 0.28.0, grpc-gateway 2.29.0, cheggaaa/pb 1.0.30, and go.yaml.in/yaml/v3 3.0.5. Larger unrelated go-openapi, pb/v3, compression, and test-library updates remain deferred.
On the frontend, the vulnerability workflow now covers pushes, manual runs, and development dependencies with immutable installs; vulnerable transitive resolutions were refreshed (fast-xml-parser 5.11, nanoid 3.3.18, @babel/core 7.29.7), dead exports and the unused http-status-codes dependency were removed. The new fast-xml-parser 5.x transitive tree (@nodable/entities, is-unsafe, anynum, fast-xml-builder, path-expression-matcher, xml-naming) was supply-chain-checked during this review: all six packages are published by the fast-xml-parser author’s own account and organization, their installed code is free of execution/network/exfiltration patterns, and the whole tree is development-only — nothing ships in the browser bundle.
Security Review
govulncheckat the release tree: zero vulnerable symbols reached, zero vulnerable imported packages. The Swagger build tool scans clean separately.- Closed by dependency moves: CVE-2026-73500 (etcd TLS listener DoS), CVE-2026-56864 / CVE-2026-56865 (x/mod verification).
- Still reported, still unreachable: the module-level GO-2026-5932 openpgp advisory — the console does not import
x/crypto/openpgp, and no fixed release exists. - The text preview was reviewed as an XSS surface (see above); its regression suite includes active-payload tests.
- The permission-gating fixes are UI-truthfulness fixes: server-side authorization was never bypassed in 2.1.1; the console simply displayed controls it shouldn’t have.
Compatibility
Nothing changes for deployment plumbing: no environment variable, configuration format, command, binary name, systemd unit, port, or embedded data layout changes. The release binary remains self-contained; Go 1.27.0 is a build-time requirement only.
HTTP API contract (console’s own REST API):
| Route | Change |
|---|---|
PUT /user/{name} |
Unchanged on the wire; now deprecated. OperationId renamed UpdateUserInfo → UpdateUserInfoLegacy, body model renamed to legacyUpdateUser (identical schema). Unknown status: 500 → 400. |
PUT /user/{name}/status |
New. Status-only body (enabled/disabled enum, 422 on violation), returns a user object populated with access key and status only — clients must not read group data from it. |
updateUser model |
Now status-only with enum — breaking for generated-spec consumers; the old shape lives on as legacyUpdateUser. |
| Object download | Error semantics changed: bad ranges 500→416 (+Content-Range: bytes */N), range-on-empty 500→200, Stat failures 500→real S3 status, ZIP failures silent-partial-200→visible failure, 206 responses carry Content-Length. |
| Listings & WebSocket | size always serialized, including 0. Additive. |
PUT /configs |
New 400 class for database DSNs the server’s config grammar would corrupt. |
| Config reset/delete | restart in the response now reflects the server’s real answer instead of always true. |
GET /session |
401 for empty-credential principals; the advertised Create-Access-Key capability is computed more strictly (see service accounts). |
If you generate client SDKs from swagger.yml: the UpdateUserInfo operation now points at /user/{name}/status with a status-only body. Code calling the generated UpdateUserInfo symbol keeps compiling but targets the new route; the old combined call is UpdateUserInfoLegacy. Audit call sites when you regenerate.
Behavior changes an operator may notice:
- Folder/ZIP downloads over partially readable prefixes fail instead of silently omitting unreadable objects (details).
- Range parsing is stricter than Go’s lenient default; degenerate range headers (
bytes=-0, empty elements) now get 416 instead of best-effort handling. - Database notification configs that only worked by accident (DSNs the config grammar mangled on the way in) are now rejected up front with a 400.
- The Create-Access-Key control is hidden for sessions whose policy unconditionally denies it (previously any conditional deny kept it visible).
- A pending restart-required indicator persists until an actual restart, instead of being clearable by a later unrelated config change.
- If a reverse proxy in front of the console compresses
/api/v1/…/downloadresponses, the text preview’s strictContent-Lengthverification will reject every preview as an error. The console itself only compresses static assets — leave API responses uncompressed at the proxy.
Regression Review
Because this release rewrites the download path and re-platforms the shared policy library, the full diff against v2.1.1 was re-reviewed adversarially in five parallel passes (Go API; object browser preview/download; permission gating and service accounts; forms/i18n/session; dependencies/build/CI), each hunting specifically for behavior that worked in 2.1.1 and silently changed.
Verdict: no unintentional regressions found. Every confirmed behavioral difference is one of the deliberate changes documented above. The review did surface two small pre-existing UI-guard defects, now visible because the dead disable-prop was brought back to life with predicates whose argument type was never right:
- the “cannot delete the Default IDP configuration” row guard compares the row object against the string
"Default"and therefore never engages (IDPConfigurations.tsx); - the “cannot delete an env-override webhook endpoint” row guard has the same object-vs-string mismatch and is inert (
WebhookSettings.tsx).
Neither is a 2.2.0 regression — both predicates were entirely dead in 2.1.1 — and in both cases the server still enforces the real rules. They remain documented follow-up items after the release. Two sharp edges are recorded as known limitations rather than defects: the new PostgreSQL DSN parser accepts only canonical key=value syntax when populating structured fields (an unusual-but-libpq-valid DSN shows empty structured fields, and editing a structured field then rebuilds the DSN from those fields), and TestCafe/Playwright coverage asserts UI gating while live-server deny-path coverage remains the integration suites’ job.
Verification
The release decision combines local release-preparation evidence with remote gates run against the exact tagged tree, 7dc4258a6:
Local release-preparation gates — go build ./..., go vet ./... (plus -tags testrunmain), gofmt, golangci-lint, go test -race ./..., go tool swagger validate, govulncheck, TypeScript tsc, Playwright, Prettier, knip, release-tag cross-compiles for linux/amd64 and linux/arm64, and go mod verify.
Embedded-assets determinism — the frontend was rebuilt from source through the full pipeline (yarn build + embed optimization) and the result compared against the committed web-app/build: byte-identical, zero dirty files. The nine commits after the earlier 19047161f candidate changed Go compatibility, workflows, and browser-test timing, but not product frontend source or embedded assets.
CI, exact final tree — Workflow run 32888892876 reported 32 successful jobs and one explicitly disabled React-test placeholder. It covered lint, semgrep, Go and API tests, five cross-compile targets, Swagger drift, latest-MinIO source builds, distributed integration, site replication, hermetic SSO, the complete TestCafe permissions matrix, subpath-nginx, Playwright, and coverage. Vulnerability Check 32888899120 passed both jobs on the same commit.
Release pipeline — goreleaser run 32916237254 passed both jobs against v2.2.0 and published the public GitHub release.
Downstream contract — in a scratch tree with every replace removed, go mod tidy + go build ./... succeed against upstream minio/pkg v3.6.1, proving the fork replacement never leaks fork-only symbols into the public module surface.
Test-infrastructure repairs shipped in this cycle (so the gates above actually gate): the Docker-backed integration/replication/SSO suites are back behind the testrunmain build tag (a bare go test ./... no longer tries to start containers); the SSO gate is hermetic — no sudo /etc/hosts edits, no ad-hoc pip installs, pinned SILO image, own port, real teardown; the integration gate asserts the new 416 range semantics and stopped binding its PostgreSQL fixture to a host port; the browser gates run off-Linux by publishing fixture ports; a stale pre-fork “MinIO administrator” selector was fixed; state-mutating TestCafe suites are serialized; Playwright CI installs the committed lockfile immutably.
Release Artifacts
The v2.2.0 GitHub release publishes:
- six standalone binaries: Linux amd64/arm64/armv6, macOS amd64/arm64, and Windows amd64;
- nine Linux packages: DEB, RPM, and APK for amd64, arm64, and armv6;
silo-console_2.2.0_checksums.txt, plus the SHA-256 digest GitHub records for every asset.
Those assets, the tag, and the release page are verified here. This page does not claim a separately distributed container image or detached signatures.
Related Commits
16960f7ab— fix: keep unknown downloads indeterminate5968bb37d— chore(deps): align the SILO Go dependency stack288ab1240— fix: harden sessions, metrics, and translationsecf3bb492— fix: harden object previews and downloads902d9650d— chore: tighten dependency and test gates194c70c7a— build: prepare SILO Console v2.2.0927b44e26— fix: harden database notification formsda2191be9— fix: clarify console upload and secret limits6141c2445— build: refresh SILO Console v2.2.0 assets097e76155— chore(deps): bump the shared package fork to v3.12.099ca523d6— fix: split user status updates out of the combined user routef4097992f— fix: honor the server restart result for configuration changesf1280032a— fix: restore permission-gated table row actions24ce0af97— fix: keep request-scoped access key conditions visible in the console92e8f4e65— fix: pass the object size into the preview and share dialogs8f6fb3c78— test: make the SSO gate hermetic and pin it to a SILO release384a2cb95— build: refresh SILO Console assets and record the changesa73cda376— test: fix the integration gate’s stale range and host portcf5049c1d— test: make the browser gates runnable and fix a stale selector6fa19d857— fix: complete service account permission boundaries7e57771a4— build: refresh SILO Console assets57cfe7aa0— fix: restore downstream and browser release gates19047161f— test: stabilize permissions browser gatese37dec873— fix: validate IAM policies before writes28505ed23— chore: update minio-go to v7.3.016abb971e— ci: harden validation and release gates2ddfcd036— fix: accept legacy AccountInfo tag payloads31332bca9— fix: preserve policy source compatibility3a8251086— ci: allow permission tests to finishc159fff78— ci: serialize shared-role permission tests2e91cdf9a— test: wait for watch controls to become ready7dc4258a6— test: allow asynchronous UI controls to settle
Links:
2.2 - Silo Console 2.1.0 Released
Published: 2026-08-06 · Version: v2.1.0 · Repository: pgsty/silo-console
SILO Console 2.1.0 is the first feature release after the independent 2.0.0. It does three things:
- Speaks two languages — every console screen, help topic, and documentation link now renders in English or Chinese, behind a toggle on every page, with zero new runtime dependencies;
- Reads the right metrics — the dashboard moves off the MinIO Metrics V2 names onto V3, with explicit handling for the semantics V3 changed underneath it;
- Stops lying in edge cases — a select-all that matched what a bulk action would delete, placeholders that survive object names containing
$&, timestamps that carry a timezone, and empty metrics that read “no data” instead of a fabricated0.
This is a minor release. No environment variable, module path, API contract, binary name, or data layout changes. Upgrading is a binary or image swap.
A 2.1.1 patch follows this release
v2.1.1, published the same day, completes the legend hardening described below: a label placeholder the legend builder cannot resolve is now removed instead of leaking literal braces into the Traffic chart legends, the one remaining substitution branch is escape-proofed against label values containing $& or $1, and the License page reports the actual release version instead of 2.0.0. Nothing else changes — upgrade straight to 2.1.1, and everything in this note applies unchanged.
Rebuild your embedded assets if you vendor this console
2.1.0 fixes a packaging defect present on the main branch after 2.0.0: the go:embed payload still carried the 2.0.0 frontend build, so a binary built from an intermediate commit would serve the old UI. The released 2.1.0 artifacts are built from the regenerated payload and are unaffected.
A Bilingual Console
The console is an administration surface for an object store, and a large share of its operators read Chinese first. 2.1.0 makes the interface bilingual without importing an i18n framework — the embedded delivery model means every kilobyte is paid for in the binary. This is issue #6, which proposed i18next; the dependency-free substitution is the one deliberate deviation from it.
How it works
The design constraint was: no new dependency, no build step, no extraction pipeline, and partial coverage must never break the page.
- English source strings are the dictionary keys.
t("Create Bucket")looks up the Chinese entry; a missing key returns the English string unchanged. Coverage can therefore grow incrementally, and a typo degrades to English rather than to a raw key likeconsole.bucket.create. - Three dictionaries, one merge.
zh.ts(165 chrome entries),zhHelp.ts(247 help-topic entries), andzhScreens.ts(1,373 screen entries) merge with chrome taking precedence — about 1,785 entries in total. - The language preference mirrors dark mode:
localStorage→systemSlice→setLanguage. There is no browser-locale detection; the default is English, and the choice is explicit. - Central interception points rather than per-callsite edits: the page-header wrapper, confirm dialogs, help items, route definitions, and the dashboard’s panel renderer each translate on the way out. This is why 220 screen files could be localized without touching their business logic.
- Module split matters.
i18n/lang.tsholds pure primitives (translate,localizeUrl) and imports no store —systemSlicedepends on it, so importing the store back would form a cycle. The hooks (useT,useLanguage,useLocalizedLink) andinterpolate()live ini18n/index.tsx.
The toggle is a stroke-drawn 文/A icon mounted in the page header on every page and reused on the login page.
What it covers
Login and SSO flows, navigation and the command palette, the dashboard and every metrics panel, buckets and the full object browser (uploads, previews, sharing, versioning, rewind), users/groups/policies/access keys, configuration and event destinations, IDP and KMS, logs, health reports, speedtest, profiling, inspect, trace, watch, and the license page.
Beyond visible strings:
- Documentation links localize.
silo.pgsty.comlinks gain a/zhprefix in Chinese; the Pigsty site swaps domains (pigsty.io↔pigsty.cc). GitHub, MinIO, AWS, and YouTube links are left alone. - The help blog feed is per-language, fetching
/zh/blog/index.xmlin Chinese, with an independent cache per language. - The command palette stays searchable in both languages. Menu entries translate for display but keep their English originals as keywords, so “桶” and “buckets” both match.
- Chart legends translate only their static prefix.
translateLegendpreserves instance suffixes like[server:drive], and the data layer keeps raw legends so components that match on them for arithmetic (capacity summing) keep working. - Timestamps are unified, not merely translated — see below.
What it costs
Roughly +61 KB on the embedded payload (2.79 MB → 2.85 MB, +2.2%), zero new dependencies, and the dictionaries land in their own lazily-loaded chunk. The English rendering path is byte-stable: with the default language, output is identical to 2.0.0.
What stays English
Backend error strings (182 of them) are produced by the Go server and are not translatable from the frontend. A handful of strings hardcoded inside the vendored mds component library — the collapsed-menu “Sign Out” tooltip, and the data table’s “Columns”, “Loading…”, and ON/OFF toggles — remain English; two of them (“Sign Out”, “Actions:”) are swapped via a scoped CSS rule, but the rest would require patching the vendor.
Metrics V3 Migration
The dashboard queried MinIO Metrics V2 names. SILO deployments scrape V3 (/minio/metrics/v3), so the dashboard depended on an endpoint the monitoring pipeline no longer collected. 2.1.0 rewrites all 26 widgets onto the V3 catalog — 31 queries over 29 distinct metric names — and drops three widgets (51/61/62) that no layout ever referenced. This is issue #7; the Info-page half is #8.
The decision is V3-only: no runtime fallback, no probing, no version-selection knob. SILO Console targets SILO deployments, where the server, the scrape pipeline, and the console ship together. The SILO server keeps serving V2 endpoints for external consumers; the console simply stopped using them. A fallback would have been actively harmful — a metrics store retaining 15 days of V2 series would let an or-fallback silently read stale data.
The semantics V3 changed
Three properties of V3 break a naive name-for-name rewrite, and each needed a deliberate answer:
- Cluster groups are exported identically by every node.
/cluster/*metrics carry no server label and are not leader-gated, so an N-node scrape yields N duplicate series. Queries aggregate withmax()/min()— neversum(), which would multiply cluster totals by the node count. - Zero values are not exported at all. Any metric whose value is ≤ 0 is skipped. Offline drive counts, healing-drive counts, and erasure-set health simply vanish rather than reporting
0, which a stat card renders as an empty panel. Every affected query carries a companion guard so the panel reads a real0. - There is no
minio_heal_*namespace. The V2 heal activity signal was in-memory anyway — it reset on restart and bumped on any scan. It is replaced by two cards with defensible semantics: Erasure Health (baselined on write quorum) and Usage Data Age (how stale the scanner’s usage snapshot is).
Zero-state semantics
An adversarial review of the migration produced eight findings, all fixed before release. They share one theme — the difference between zero, no data, and not yet scanned:
- Capacity free/used baselines on the always-present total, so a full cluster reads
0 freeinstead of vanishing. - Online Drives is guarded against the all-offline case, where the zero-skip would erase the panel exactly when it matters most.
- Bucket and object counts guard on the usage group’s own freshness gauge, so a cluster that has not completed its first scan reads no data rather than a fabricated
0. - Empty single-value results render as
—, not0. - An empty size distribution no longer fabricates seven zero-height bins.
- Fractional rates stay visible (
parseFloataxis domain, two-decimal CPU formatter) instead of collapsing to0. - Sub-second Usage Data Age clamps to “1 second” instead of rendering blank.
A regression suite (api/admin_info_metrics_test.go) now pins every widget query to the V3 catalog, asserts widget-ID uniqueness, and enforces the per-widget guard taxonomy: health and traffic widgets need a nodes-online companion, usage counts need the usage-group freshness companion, and capacity needs the total baseline. The full mapping is documented in docs/metrics-v3.md.
Also fixed
- Widget 17 queried
sent_bytestwice and widget 11 queriedsyscall_readtwice — both internode/syscall pairs were transposed into duplicates. - Label-less matrices (the result of
max()aggregation) serialize with nometricfield at all, which crashed the frontend’s label extraction and produced a0 Bcapacity donut and an empty usage-growth chart. Guarded. - An unused per-widget Prometheus label-values prefetch stalled every widget request by up to a second. Deleted.
- The dashboard’s usage cards, chart controls, and dense Traffic/Resources panels were rebuilt on one grammar and now reflow through tablet widths.
Two server-side bugs were identified during this work and are tracked upstream rather than worked around here: minio_cluster_usage_buckets_since_last_update_seconds emits nanoseconds (the objects variant is correct), and V3 bucket-level sent/received traffic are transposed.
Correctness Fixes
Placeholders that survive real object names
String.prototype.replace interprets $&, $', $`, and $1 in the replacement value as directives. S3 keys legally contain $. So an object named report$&.csv did not render as itself — it re-injected the matched placeholder text into the output and corrupted the message. All 37 dictionary placeholder substitutions now pass the value through a function replacement, where no such interpretation happens. This was a latent bug in the original English UI, not something i18n introduced; the i18n audit is simply what found it.
A select-all that means what it shows
The vendored data table renders a plain untranslatable “Select” header whenever onSelectAll is absent — which was the case on all seven selectable tables. Worse, the naive fix is wrong: a select-all that replaces the whole selection drops rows hidden by an active filter, so the header checkbox and a subsequent bulk action can target different sets. The implementation toggles only the currently visible rows and preserves filter-hidden selections, so the header state can no longer imply a different set than the action would touch.
Timestamps with a timezone
Bucket, object, version, rewind, and access-key timestamps rendered as a mix of verbose English forms and — in several places — a 12-hour clock without AM/PM, which is simply ambiguous. All of them now render as yyyy-MM-dd HH:mm[:ss] (ZZZZ) in both languages.
A translation runtime that survives live data
t() also receives runtime strings: user agents, RSS titles, object names. Two hardening changes followed:
- misses return unchanged, unconditionally — the implicit
@contextsuffix stripping is gone, because it silently mutated live data that happened to contain@; - dictionary lookups are guarded with
hasOwnProperty, so a hostile input naming an inheritedObject.prototypemember (constructor,toString) cannot leak a function into the UI.
Interaction and accessibility
- An expired session opening a deep link bounced through
/loginand back, accumulating a redirect chain instead of landing on the form once (#1). - Collapsed sidebar buttons carried no accessible name; screen readers announced them as unlabelled (#4). Access Key inputs now declare their autocomplete intent instead of letting password managers guess (#5).
- Mobile metrics and bucket panels scroll instead of clipping (#3).
- The speedtest control row wraps instead of overflowing its card, its duration accepts seconds or minutes, and its size defaults to MiB to match its own unit list.
- Sidebar bucket rows use a virtual row pitch matching the 44px item, so selected and hovered highlights no longer overlap.
- Unit chips render the selected unit’s label rather than its raw value.
No SUBNET, No Telemetry
Upstream removed Subnet, Registration, and Call Home; this fork inherited that state but still carried three traces. 2.1.0 removes them:
- the health websocket’s
subnetResponsefield never addressed a subnet — it is a sentinel meaning “the report was assembled” — and is nowreportStatus: "ok"; - two help topics claimed the health report “uploads automatically to SUBNET” and that inspect output is “transmitted to SILO SUBNET”. Neither was true. They now describe what happens: the report is generated on the deployment and downloaded by the browser;
- the unreferenced
CONSOLE_SUBNET_PROXYconstant is deleted.
For the record, 2.1.0’s outbound network posture is unchanged and remains: no analytics, no telemetry, no beacons, no external scripts or fonts. silo-console update is still disabled. The release catalog is contacted only if SILO_RELEASE_SERVICE_HOST (or RELEASE_SERVICE_HOST) is explicitly set — there is no default. The only automatic outbound request the browser makes is the help panel’s blog feed, and only after a user opens the Blog tab.
Upgrade Guide
There is nothing to migrate. No environment variable, module path, protocol field, systemd unit, binary name, or data layout changes between 2.0.0 and 2.1.0.
Two things are worth knowing:
- The dashboard now requires Metrics V3. If your Prometheus scrapes only the V2 endpoints, dashboard panels will read no-data. Point the scrape at
/minio/metrics/v3; Pigsty-managed deployments already do. - The language default is English, chosen per browser and stored in
localStorage. There is no server-side default and no browser-locale detection, so no existing deployment changes appearance on upgrade.
Verification Scope
Before tagging, the full change set was reviewed and the following gates were run against the final tree: go build, go vet, golangci-lint (0 issues), the Go unit suite across all packages, gofmt, TypeScript type checking, the frontend production build, Prettier across all sources, dictionary duplicate-key checks, and a debug-leftover scan of the complete diff.
The 29 intermediate commits were restructured into 20 logical ones by pure tree operations, and the rebuilt tip was verified byte-identical to the pre-rewrite tree. The embedded payload was rebuilt twice from a clean directory and confirmed byte-identical, which is the property the release pipeline’s zero-diff gate depends on. The pre-rewrite history is retained in a backup ref.
The Metrics V3 migration was additionally reviewed adversarially by an independent model, and all eight findings were fixed (see Zero-state semantics); its queries were validated against a live metrics store with real cluster data.
Known Limitations
- The SSO end-to-end suite requires an external OpenLDAP/Dex/MinIO topology and was not run in that environment this cycle; the OIDC code paths are covered by unit tests.
- Backend error strings and several vendored
mdscomponent strings remain English (see What stays English). - Chinese translation covers the console’s own surfaces; help-topic bodies are translated, but the documentation pages they link to follow the docs site’s own language coverage.
- Two server-side V3 metric bugs (nanosecond bucket-usage age, transposed bucket traffic) are tracked upstream and are not worked around in the console.
- Automatic self-update remains disabled; upgrades are explicit.
Issues Closed
2.1.0 closes every issue filed against 2.0.0. Each carries a comment on the tracker describing the fix, the commits, and the coverage added.
| Issue | Resolution |
|---|---|
#1 — unauthenticated deep routes recurse /login |
Absolute, base-path-aware login destination; deep-link and subpath test coverage |
| #2 — stale Uptime, malformed legends, cramped menus | Uptime derived from real server state, legends resolve on the V3 name label, 32 px chart controls, popup width floors |
| #3 — 390 px viewport clips content | Scrollable metrics tab strip; bucket table with a deliberate mobile column budget |
| #4 — unnamed collapsed sidebar buttons | Labels visually hidden rather than removed from the accessibility tree; named, keyboard-operable collapse toggle |
| #5 — Access Key fields lack autocomplete metadata | Field-level username / new-password tokens in a dedicated autofill section |
| #6 — English/Chinese localization | Hand-rolled bilingual layer, zero new dependencies, English-as-key fallback |
| #7 — migrate monitoring queries to Metrics V3 | V3-only; 26 widgets, 31 queries, 29 metric names, guard taxonomy, regression suite |
| #8 — replace N/A Info metrics | Erasure Health and Usage Data Age, sharing the advanced dashboard’s widget results |
Three acceptance criteria are recorded as unmet rather than quietly ticked: web-app has no unit-test runner, so the i18n test suite (#6) and the focused constructLabelNames test (#2) would require introducing test tooling first, and #6’s contributor documentation for adding translation keys is not yet written.
Related Commits and Links
The complete v2.1.0 change set consists of 20 logical commits. The v2.1.0 tag additionally carries three later documentation commits that rewrote the repository README; they change no shipped behavior.
8764f5d— fix(web): stop recursive login redirects437c56c— fix(ui): make the dashboard and bucket list usable on narrow screens85fc0c6— fix(a11y): name collapsed sidebar controls and credential fieldse3fed07— fix(metrics): rebuild dashboard cards, chart controls, and layoutfa11576— feat(login): polish controls and legal attribution9fc17c1— feat(i18n): add hand-rolled EN/ZH core, dictionaries, and language toggle622c02e— feat(i18n): localize login, navigation, and the help system6a03719— feat(i18n): localize dashboard and metrics screens14b1c2d— feat(i18n): localize bucket and object browser screens0298062— feat(i18n): localize identity, configuration, and event destinations41094f6— feat(i18n): localize observability, admin tools, and shared componentse964992— feat(metrics): migrate the dashboard to MinIO Metrics V30b2251f— fix(i18n): harden the translation runtime for live data and chart legends9b60148— fix(console): unify timestamps on a timezone-carrying standard formatbf110ae— fix(console): give selectable tables a visible-rows select-all5fc8f22— fix(i18n): escape-proof all placeholder substitutionsfef8fab— fix(console): polish speedtest, sidebar, and help chromec4911e8— chore(console): drop SUBNET remnants from health reporting1d631c4— docs: record the SILO Console v2.1.0 changelog912d847— build: regenerate optimized embedded web assets
Links:
2.3 - Silo Console 2.0.0 Released
Published: 2026-08-04 · Version: v2.0.0 · Repository: pgsty/silo-console
SILO Console 2.0.0 is the first major release of this object-storage administration console as an independent project. Continuing from the georgmangold/console v1.9.1 maintenance line, it accomplishes three things:
- An independent identity — product name, visual system, documentation entry points, source attribution, and the release pipeline all move into the SILO project, while the Go module path, environment variables, and other compatibility contracts are deliberately retained;
- A redesigned interface — the login page, theme system, dashboard, and console details are reworked under one design language, backed by a regenerated brand icon set;
- Hardened engineering — the embedded frontend payload shrinks from roughly 10MB to 3.5MB, known dependency vulnerabilities drop to zero, and a batch of inherited defects — including a real runtime data race — is fixed.
Before publication this release went through two independent review passes: a full code review with commit-history restructuring, followed by an adversarial re-verification (exhaustive asset validation, HTTP semantics probing, full routing regression, and smoke tests against the published artifacts themselves).
Read the compatibility boundary before upgrading
The major-version change in 2.0.0 is about public identity and delivery contracts, not the object data format or the S3 protocol. Installation scripts that reference the old repository, binary name, or container image must be updated; existing integrations that use CONSOLE_MINIO_SERVER, CONSOLE_MINIO_REGION, github.com/minio/console, or the MinIO-compatible Admin API must not be search-and-replaced.
Why 2.0.0
This console originated as MinIO Console and was carried forward by the Alevsk/console and georgmangold/console community maintenance lines. SILO Console continues from there, maintained by the Pigsty community as the browser-based administration interface for SILO.
The version jumps from v1.9.1 to v2.0.0 because these public contracts change together:
- the product is now uniformly SILO Console, with the primary repository at
pgsty/silo-console; - the release binary changes from
consoletosilo-console, and the container image moves toghcr.io/pgsty/silo-console; - release assets, checksums, package metadata, CLI descriptions, and project links all switch to SILO;
- in-product identity, help entry points, copyright attribution, source offers, and trademark notices are re-established.
The migration strategy is “clear external identity, restrained internal compatibility”: operators must take notice, but the underlying compatibility interfaces are not mechanically renamed.
Naming and Delivery Contracts
| Scope | Previous name or location | 2.0.0 contract |
|---|---|---|
| Product | Console / legacy MinIO Console | SILO Console |
| Repository | georgmangold/console |
pgsty/silo-console |
| Release binary | console |
silo-console |
| Container image | ghcr.io/georgmangold/console |
ghcr.io/pgsty/silo-console |
| Binary assets | console-<os>-<arch> |
silo-console-<os>-<arch> |
| Checksums | console_<version>_checksums.txt |
silo-console_<version>_checksums.txt |
| Website and docs | upstream / previous maintainer | silo.pgsty.com and silo.pgsty.com/docs/ |
CLI authorship, usage text, and project descriptions now identify Pigsty and SILO Console. DEB/RPM/APK vendor, maintainer, homepage, description, and license metadata are updated accordingly; the executable installs to /usr/local/bin/silo-console.
Deliberately Retained Compatibility Identifiers
The following names still contain minio or the old console, but they are interface, protocol, or installation compatibility layers — not leftover branding:
| Surface | State in 2.0.0 | Reason |
|---|---|---|
| Go module | github.com/minio/console retained |
changing it breaks every Go import |
| Server endpoint | CONSOLE_MINIO_SERVER retained |
widely used by existing deployments |
| Server region | CONSOLE_MINIO_REGION retained |
existing compatibility contract |
| Other configuration | existing CONSOLE_* variables remain valid |
avoids migration with no benefit |
| S3/Admin API names | MinIO-compatible fields and enums retained | they describe the actual protocol |
| Development build | make console still produces ./console |
keeps developer workflows working |
| Package systemd unit | minio-console.service retained |
avoids duplicate services on upgrade |
| systemd user and config | console-user and /etc/default/console |
avoids unnecessary account/config migration |
Upgrade scripts therefore must not run repository-wide minio → silo or console → silo-console replacements. Migrating these compatibility interfaces in the future will require aliases, deprecation windows, and an explicit dual-read strategy; 2.0.0 does none of that.
A Redesigned Interface
2.0.0 is not a logo swap — the interface was redesigned end to end.
Login page
The login page is rewritten from scratch. The left brand panel renders a slowly drifting sine-mesh animation generated purely on Canvas (zero external dependencies, honors prefers-reduced-motion, pauses in background tabs), states the project’s proposition — “Keep the S3 Interface / Own the Object Store” — and keeps the full MinIO trademark notice at the bottom. The right-hand form is functionally untouched, preserving every existing automation selector. The Chakra Petch typeface used by the SILO wordmark ships as a ~20KB locally bundled subset with no external requests.
A unified theme system
All console colors converge into one light/dark theme layer: neutral greys for text and borders, the brand steel blue for primary actions and selection, and a sidebar that uses the same night palette as the login panel in both modes. Controls and cards share consistent radii and transitions, inputs get a keyboard focus ring, and modals animate in (also honoring reduced motion). Server-provided customStyles keep full precedence.
Console polish
- Dashboard (Metrics): stat cards rebuilt under one grammar — muted labels, tabular numerals, aligned status dots; charts and info strips are theme-driven; the upstream absolute-positioning layout is gone.
- Unified empty states: placeholder text in Watch, Trace, bucket Events/Replication/Lifecycle, and every other data panel is now centered and de-emphasized instead of raw top-left text.
- Vertical tabs: detail-page tabs change from bordered grey blocks to a quiet pill list, eliminating the stray empty cell at the bottom of the rail.
- License page: a new VERSION section shows both the connected server’s release and the Console’s own version; accounts without
admin:ServerInfonever issue the request and the row stays hidden. The page also consolidates AGPLv3 licensing, the AGPL section-13 source offer, lineage, and trademark boundaries. - A batch of interaction fixes: the sidebar now collapses on initial load at mobile widths (previously it waited for a resize event); the bottom navigation no longer lags window-height changes; the bucket accordion highlight spans the full row; the dashboard no longer overflows horizontally on narrow screens; and the help panel is now truly lazy — the login page makes no external requests at all.
Brand icon set
The favicon, PWA, and Apple Touch icons still carried a previous-generation hand-drawn emblem. 2.0.0 re-rasterizes every size (ico 16+32, favicon 16/32/96, apple 180, manifest 192/512) from the official silo.svg vector emblem, with safe-area margins on home-screen sizes, and trims the Web App Manifest to the modern icon set, dropping the 2014-era legacy density entries. The icon payload drops from 473KB to 160KB, and the browser tab icon finally matches the in-product brand.
Smaller and Faster
Embedded delivery is this console’s core form factor — the frontend ships inside the binary via go:embed. 2.0.0 optimizes that path systematically:
- Embedded payload: ~9.6MB → 3.5MB. Text assets (JS/CSS/SVG/…) are precompressed at build time with deterministic gzip and embedded compressed-only; legacy WOFF fonts (~1.25MB that no supported browser ever downloads) and a set of entirely unreferenced orphan images are removed.
- First-load transfer: ~5.7MB → ~1.7MB. Static assets previously shipped uncompressed on the wire; they are now emitted directly with
Content-Encoding: gzipat zero runtime cost, with on-the-fly decompression for the rare client that does not accept gzip. - Correct HTTP semantics. Accept-Encoding is parsed with full RFC 9110 q-values (
gzip;q=0gets identity bytes), responses carryVary: Accept-Encoding, and non-GET/HEAD requests to static paths and the SPA entry receive 405 with anAllowheader. - Reproducible builds. Compression uses a pure-JS implementation (fflate) for byte-identical output across platforms, and the release pipeline enforces a hard gate: rebuilding the embedded assets in a clean environment must produce zero diff against the commit.
Release binaries (all frontend assets included, stripped) weigh roughly 35–40MB; for the downstream SILO server, embedding this console now costs about 3.5MB instead of about 10MB.
Security and Dependencies
Go: the build baseline moves to Go 1.26.5 and the golang.org/x family is fully refreshed. Every reachable vulnerability reported by govulncheck is resolved:
| Dependency | Fixed version | Advisories |
|---|---|---|
google.golang.org/grpc |
v1.82.1 | GO-2026-6061 |
github.com/prometheus/prometheus |
v0.311.3 | GO-2026-5710 / -5662 / -5381 / -5264 (incl. remote-read DoS) |
github.com/klauspost/compress |
v1.18.7 | GO-2026-5841 |
The single remaining advisory sits in golang.org/x/crypto, has no upstream fix yet, and is unreachable from this codebase; it is tracked as a known item.
Frontend: the full dependency-tree audit (production and tooling) is clean, covering the high-severity form-data CRLF injection and the DOMPurify and qs advisories; React Router is migrated to 7.18.2 (keeping the v6-compatible declarative API, with full routing regression). The only explicitly ignored advisory affects an unstable API this project does not use.
Runtime correctness: a real data race between HTTP log-target initialization and shutdown is fixed, along with shared-mock races in the test suite; supported Go packages pass -race across the board. As a side benefit, the go-m1cpu upgrade fixes the local go run cgo crash on recent macOS.
Update Checks and Default Network Behavior
This release keeps conservative defaults for upgrade tooling:
- automatic self-update in
silo-console updateis disabled — the command prints guidance and never downloads or replaces the binary; - the release catalog gains
SILO_RELEASE_SERVICE_HOST, with the previousRELEASE_SERVICE_HOSTas a compatibility fallback; with neither set, no remote release service is contacted; - the help panel’s blog content loads only when opened, and its links accept
https://silo.pgsty.comexclusively.
Automatic updates will be reconsidered once signed release assets and a tested rollback path are in place.
Release Artifacts and Platform Matrix
The release ships 16 assets:
| Type | Coverage |
|---|---|
| Standalone binary | Linux amd64/arm64/arm, macOS amd64/arm64, Windows amd64 |
| System packages | DEB / RPM / APK × amd64/arm64/armv6 |
| Checksums | silo-console_2.0.0_checksums.txt (SHA-256) |
The pipeline triggers on tag pushes, pins third-party Actions to commit SHAs, and enforces the clean-checkout and zero-diff asset-rebuild gates before GoReleaser runs.
Upgrade Guide
Standalone binary
When building from source, make console still produces ./console; install it under the release name before wiring it into a production service.
DEB/RPM/APK and systemd
Packages continue to install /etc/systemd/system/minio-console.service, whose unit starts /usr/local/bin/silo-console. EnvironmentFile=/etc/default/console, console-user, and existing CONSOLE_* variables are unchanged. This retention lets package upgrades keep acting on the existing service instead of creating a parallel one.
Configuration and integrations
- do not rename
CONSOLE_MINIO_SERVERorCONSOLE_MINIO_REGION; - do not touch
github.com/minio/consolein Go imports; - prefer
SILO_RELEASE_SERVICE_HOSTfor self-hosted release catalogs; - replace any reliance on
console updatewith explicit download, verification, and deployment; - update process-path-based monitoring to
/usr/local/bin/silo-console.
This release does not change the object data layout and requires no bucket or object migration.
Dual Review and Validation Scope
2.0.0 went through two independent review passes before publication. The first pass performed a full code review, fixed the defects described above, restructured 13 intermediate commits into 8 logical ones, and ran Go -race across supported packages, go vet, golangci-lint, govulncheck, frontend type checks, production builds, Prettier, dead-code checks, and the full dependency audit. The second, adversarial pass independently re-ran the core gates and added:
- all 184 embedded files fetched three ways each (gzip client, identity client, HEAD) with per-file hash comparison against the embedded sources;
- RFC semantics probes (including combined q-values such as
gzip;q=0, *;q=0.5), method restrictions, the OIDC callback, and SPA deep links; - full React Router 7 regression: deep links, client-side navigation, bucket-detail tab switching, and browser history back;
- mobile first-load sidebar behavior, login-page external-request monitoring, and light/dark full-site tours;
- downloaded release assets verified byte-for-byte against checksums, binary self-reported version confirmed, and a smoke test of the published binary against a live server;
- zero-diff asset rebuilds confirmed on both macOS and Linux.
The complete pre-rewrite history is preserved in backup refs for rollback.
Known Limitations
- automatic self-update is disabled; upgrades are explicit;
- the SSO end-to-end suite requires an external OpenLDAP/Dex/MinIO topology and was not run in that environment this cycle (the OIDC code paths are covered by unit tests and HTTP-level checks);
- one
golang.org/x/cryptoadvisory has no upstream fix yet and is unreachable from this codebase; - SILO does not yet maintain its own video library; videos in the help panel are clearly labeled upstream compatibility material;
- administrative features depend on the MinIO-compatible Admin API — SILO Console is not a generic browser for arbitrary S3 services;
- retained Go module paths, environment variables, protocol fields, and the systemd unit name still appear in code, configuration, and process listings.
Related Commits and Links
The complete v2.0.0 change set consists of 8 logical commits:
50797de— feat: establish SILO Console identity and compatibility23ae6e8— feat: redesign and harden the SILO Console web app7a83a77— build: update Go toolchain and dependencies1330d25— fix: eliminate logger shutdown and test mock races06b3a34— docs: publish the SILO Console v2.0.0 guide4b24372— build: regenerate optimized embedded web assetsc38eb64— ci: package and publish SILO Console v2 releasesb952a12— brand: regenerate the icon set from the official silo.svg emblem
Links:
2.4 - silo-pkg 3.12.0 Released
Release date: 2026-08-24 · Version: v3.12.0 · Commit: 2b087a1 · Repository: pgsty/silo-pkg
Version 3.12.0 is a main-line minor release with two themes: a policy-write guard for ARN prefixes that name no resource, and the maintained Go 1.27 / etcd 3.7 dependency baseline. It adds two exported inspection/validation methods, raises the verified consumer floor to Go 1.26, and is the first silo-pkg release whose strict validation path is enabled by the SILO server for named-policy and service-account policy writes.
This package release and a SILO server release are different gates. The package tag and GitHub Release are public. SILO main consumes it in eee05a17c, with the operator note recorded as SN-2026-005. No date-style SILO server tag, container image, package set, deployment, or production rollout is established by this article.
Release at a Glance
- Published: the
silo-pkg v3.12.0tag, GitHub Release, and strict library validation for bare ARN prefixes. - On SILO remote
main: named-policy and service-account policy write integration. - Deliberately permissive: existing IAM policy loading, IAM import, and site-replication receive paths.
- Deferred: STS inline-policy strict validation and Console-side early validation; the server remains authoritative.
- Not part of this release: a SILO server binary, image, package, deployment, or production rollout.
Bare ARN Prefixes Were Policy No-ops
An S3 resource ARN needs a resource after its namespace prefix:
The policy parser also accepted the prefix by itself:
That string names no bucket or object. The existing parser normalized it to the wildcard resource type while retaining arn:aws:s3::: as the match pattern. Real S3 authorization candidates look like bucket or bucket/object, so the pattern normally matched nothing even though the policy validated successfully.
For statements that actually perform resource matching, the impact depends on Effect and on whether the prefix appears in Resource or NotResource:
| Statement shape | Existing runtime result |
|---|---|
Allow + bare Resource |
Grants nothing |
Deny + bare Resource |
The intended denial does not fire |
Allow + bare NotResource |
Excludes nothing and can grant far more than intended |
Deny + bare NotResource |
Can deny far more than intended |
The dangerous cases are policy-dependent fail-open behavior, not an unauthenticated remote exploit and not a CVE. A policy author, template, or automation must first submit the malformed resource. The same issue applies to registered S3 Tables and KMS ARN prefixes.
Resource-less admin actions, sts:* action statements, and the first phase of two-step KMS authorization bypass resource matching; a bare prefix does not change their existing runtime decision. Strict writes still reject the deceptive field so a policy cannot look scoped when that scope is ignored.
The Historical *arn:... Spelling
On the permissive compatibility path inherited from earlier releases, serializing a parsed bare prefix adds the wildcard type marker:
Re-parsing either spelling produces the same internal resource value. The 3.12 guard therefore recognizes both the exact prefix and its historical star-prefixed serialization. This matters for stored/exported policies and for clients that parse and marshal a document before sending it to the server.
The accepted wildcard corpus remains unchanged: *, **, ***, *foo, and explicit resources such as arn:aws:s3:::* still parse as before.
A Strict Write Path, Not a Storage Migration
The fix deliberately separates policy loading from policy creation:
ParseConfigandValidateremain permissive. Existing stored policies keep loading and evaluating with the same matching and serialization behavior.ParseConfigStrictandValidateStrictreject a registered ARN prefix that names no resource, in bothResourceandNotResource.Resource.IsBareARN()detects the normalized exact/historical form without changing the exportedResourcestructure,ParseResource, matching, or JSON representation.ResourceSet.ValidateStrict()exposes the strict resource-set check to consumers.
Keeping the existing resource representation is important for mixed-version sites: v3.11 and v3.12 nodes continue to compare and serialize stored policies the same way, so this fix does not create a site-replication mismatch or require a storage migration.
SILO Enables the Guard on Three Boundaries
SILO commit eee05a17c selects silo-pkg v3.12.0 and uses strict parsing when:
- creating or replacing a named IAM policy;
- creating a service account with an inline session policy; and
- updating a service account’s inline session policy.
Compatibility-sensitive paths stay permissive in this rollout:
- loading named policies and embedded policies already at rest;
- IAM import/restore;
- site-replication receive and apply paths;
- STS inline session policies; and
- bucket policies, whose existing bucket/action validation already rejects these forms.
Enabling ParseConfigStrict also activates two pre-existing admin-policy checks: one admin statement may not contain both Resource and NotResource, and a bucket-scoped admin action may not use a non-S3 resource. These are intentional authorization tightenings and are documented in SN-2026-005.
In this article, bare ARN prefix means an ARN namespace with no resource after it, such as arn:aws:s3:::. It is different from the valid bare bucket ARN used in the 3.11 bucket/object-boundary fix, such as arn:aws:s3:::my-bucket.
Operator Action
Existing policies are not rewritten automatically because the intended resource cannot be inferred. Before deploying a SILO server build that contains the strict integration:
- inspect named IAM policies for exact or historical bare prefixes;
- inspect service-account inline policies;
- replace each finding with the intended concrete resource; or use a suffix wildcard only if all resources in that namespace are truly intended; and
- repeat the audit after all sites have completed the rolling upgrade.
Do not blindly turn every finding into arn:aws:s3:::*: that could replace an inert statement with a cluster-wide grant or denial. A legacy policy containing a bare prefix still loads, but it cannot be submitted unchanged through the three strict write endpoints; correct it before editing another property on the same policy or service account.
The safer audit path uses policy-info and access-key-info APIs rather than a full IAM export, because a complete export contains user and service-account secrets. STS strict validation remains deferred until live machine clients and their session-policy templates can be audited separately.
Why This Is a Minor Release
This is v3.12.0, not v3.11.1, because the release combines three compatibility-relevant changes:
- the etcd client crosses from the 3.6 minor line to 3.7;
- the module’s verified
gofloor rises from 1.25.0 to 1.26.0; and - the package adds exported bare-ARN inspection and strict resource-set validation APIs.
The module path remains github.com/minio/pkg/v3; the /v3 import suffix and every existing import site stay unchanged.
Go and Dependency Baseline
The go and toolchain directives have separate purposes:
go 1.26.0is the supported consumer floor required by the selected etcd 3.7 modules.toolchain go1.27.0is the maintained development and CI baseline.- CI actions move to the Node 24 runtime.
Key selected versions change as follows:
| Module | 3.11.0 | 3.12.0 |
|---|---|---|
go.etcd.io/etcd/{api,client/pkg,client}/v3 |
3.6.6 |
3.7.1 |
golang.org/x/crypto |
0.54.0 |
0.55.0 |
golang.org/x/net |
0.57.0 |
0.58.0 |
golang.org/x/text |
0.40.0 |
0.41.0 |
github.com/minio/minio-go/v7 |
7.0.97 |
7.0.99 |
github.com/minio/mux |
1.8.2 |
1.9.2 |
github.com/cheggaaa/pb |
1.0.29 |
1.0.30 |
github.com/lestrrat-go/jwx/v3 |
3.0.12 |
3.0.13 |
github.com/lestrrat-go/httprc/v3 |
3.0.1 |
3.0.6 |
github.com/grpc-ecosystem/grpc-gateway/v2 |
2.27.3 |
2.29.0 |
go.uber.org/zap |
1.27.1 |
1.28.0 |
Smaller selected updates include uax29 2.3.1, fastjson 1.6.10, secp256k1 4.4.1, and goccy/go-json 0.10.6. The old lestrrat-go/option v1, gogo/protobuf, and stale test-only requirements leave the selected graph.
etcd 3.7.1 is the first fixed release on the 3.7 line for GO-2026-6107 / CVE-2026-73500, an unauthenticated TLS-listener denial of service. Updating these Go client modules does not upgrade an operator’s external etcd server. SILO does not use the removed grpc.WithBlock behavior, and client compatibility with an etcd 3.6.14 server was exercised during release validation.
The dependency graph declares coreos/go-systemd 22.7.0, but the module replaces it with 22.6.0 because 22.7.0 does not compile on NetBSD. The SILO server carries the same portability override.
Compatibility
- Import paths and the module major remain unchanged.
- Existing policies keep loading and evaluating unchanged; only strict create/update calls reject the malformed prefixes.
- No policy, IAM database, wire protocol, or etcd data migration is performed.
- Downstream users of
grpc.WithBlockin etcd client dial options must migrate to a supported readiness check; SILO does not use it. - A real external etcd cluster upgrade remains a separate operational procedure.
- Upstream AIStor Memory and new AIStor-only S3 Tables action vocabularies are not imported by this fork.
Consumers select the release with:
Verification
The tagged package passed:
- the repository’s complete
make testgate: pinned lint plusgo test -race -tags kqueue ./...; - targeted bare-ARN tests repeated to disturb Go map iteration order;
go mod verify,go vet,git diff --check, andgovulncheckwith zero reachable vulnerabilities; and- an implementation-level Claude Opus Max review with a GO verdict and no P0/P1 findings.
The SILO integration passed:
- the complete IAM server suite, including exact/historical named-policy rejection and service-account create/update rejection;
go test ./cmd -count=1,go vet ./cmd, andgo test ./...;- golangci-lint 2.13.1 with zero findings,
go mod verify, andmake check-gen; and - a second Claude Opus Max review with a GO verdict; mutation tests proved all three strict call sites and their integration assertions are load-bearing.
Direct VCS module resolution verified v3.12.0 at commit 2b087a1 with module checksum:
The release environment could not reach proxy.golang.org or sum.golang.org because those connections timed out, so public-proxy observation is not claimed as release evidence. The Git tag, GitHub Release, direct module archive, origin commit, and checksum were verified.
Related Changes
2bc3a91: move CI actions onto Node 24c8c6872: align the SILO Go dependency stack2b087a1: reject bare ARN prefixes on strict policy writes; taggedv3.12.030c49bd: update the README dependency example after the tageee05a17c: enable strict named-policy and service-account writes in SILO56c67dacf: recordSN-2026-005
What Is Not Released Here
This article does not claim a new SILO server version, binary, package, container image, deployment, production rollout, Console release, or strict STS rollout. Those remain separate release gates and must be reported separately when completed.
2.5 - Silo Pkg 3.11.0 Released
Release date: 2026-08-04 · Version: v3.11.0 · Commit: d8b1fa7 · Repository: pgsty/silo-pkg
This is the fork’s first pinned release. It restores the IAM bucket/object resource boundary reported as upstream minio/minio#20449: a policy condition-key bypass fix, three LDAP connection defects, a certificate watcher leak, a seeded-RNG defect, and the module’s real minimum Go version.
Two things to check before upgrading
- This release tightens authorization. Twelve bucket-level write actions are no longer reachable through an object-only resource pattern such as
arn:aws:s3:::bucket/*. If you write your own bucket-scoped policies, read The IAM bucket/object boundary — the fix is one line of policy for anyone affected, andMINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=onrestores the previous behaviour in full. - The condition-key fix still needs its server half. The policy lookup change and the server changes that reserve internal condition-key names each cover one half of that problem. The companion server work exists in
pgsty/miniocommit2f55347f7but is not yet on publicorigin/master, and no published Silo server release contains it. Verify that a later server release explicitly includes it.
What This Repository Is
silo-pkg is a maintained fork of minio/pkg, carrying fixes needed by community MinIO forks that the now commercially driven upstream no longer accepts. The repository was renamed from pgsty/minio-pkg on 2026-08-02.
The module path intentionally remains unchanged as github.com/minio/pkg/v3. Existing import "github.com/minio/pkg/v3/..." statements do not change; only the right-hand side of the replace directive does:
The /v3 suffix is the module’s major version, not a directory name, and must not be omitted. It is also why this release is numbered v3.11.0 rather than v4.0.0: Go requires the major version of a tag to match the major-version suffix declared in go.mod, so a v4.0.0 tag on a .../v3 module is rejected by the toolchain. Publishing a real v4 would mean changing the module path and rewriting roughly 395 import sites across the server, mc and Console — abandoning the drop-in property that is the point of keeping upstream’s path.
The IAM Bucket/Object Boundary
Every bucket-level S3 operation authorizes with an empty object name. The IAM matcher turned that into a resource string and, for the empty-object case, appended a trailing slash:
"bucket/" is matched by the wildcard pattern "bucket/*", because * matches the empty string. A policy granting s3:* on arn:aws:s3:::bucket/* — which reads as “anything, but only on the objects in this bucket” — therefore also authorized bucket-level actions. In a multi-tenant cluster, a tenant holding only that grant could call PutBucketPolicy and install {"Principal":"*"}, making the bucket publicly readable or writable, or grant itself bucket-level control. It could also delete the bucket outright, which is the reproduction in the upstream issue.
The bucket-policy evaluation path used for anonymous access never had this slash and was already reference-correct. Only the IAM path was wrong, in exactly one place.
Why not correct the whole boundary
Removing the slash for every bucket-level request is the obvious fix, and upstream tried it: the change was reverted the same day for breaking policies that relied on the old behaviour. Two properties make the full correction a migration rather than a patch.
It revokes grants real deployments depend on. It does not only revoke the dangerous bucket writes — it also revokes ListBucket, GetBucketLocation and ListBucketMultipartUploads when granted through bucket/*. The evidence is upstream’s own test suite: eleven STS integration tests grant s3:ListBucket on bucket/* and then assert that listing works. If the project that wrote the server writes it that way, production policies do too.
It cuts both directions. The matcher builds the same resource string for Allow and Deny, so removing the slash tightens over-granting Allow statements and simultaneously loosens over-blocking Deny statements. An administrator who locked a bucket with Deny s3:* on bucket/* would silently lose that protection.
How the protected set was chosen
The scope was decided by one question: does reaching this action give the caller something its object-scoped grant does not already provide?
That question is the right one because of how the defect fires. Resource matching runs after action matching, so the bug only bites when the statement already grants the bucket-level action — which in practice means s3:*. The affected principal therefore already holds full read, write and delete over every object in the bucket. The useful question is not how dangerous an action sounds in the abstract, but what reaching it adds to a position that already includes all of the data.
Withheld from object-only grants (twelve actions):
| Action | Why it qualifies |
|---|---|
PutBucketPolicy, DeleteBucketPolicy |
Hand access to other principals, anonymous included, and can grant the caller bucket-level actions it was never given. Self-escalation and public exposure. |
PutBucketObjectLockConfiguration, PutBucketVersioning |
Defeat protections that exist precisely to stop a holder of write access from destroying data. |
PutReplicationConfiguration, PutLifecycleConfiguration |
Act under server credentials and keep acting after the caller’s access is revoked. |
DeleteBucket, ForceDeleteBucket |
Destroy the bucket entity and its configuration irreversibly. The reproduction in the upstream issue. |
PutBucketCors, DeleteBucketCors, PutBucketQOS, PutInventoryConfiguration |
No server behaviour is attached to these today — no handler at all, or a handler that returns NotImplemented after the authorization check. Withholding them costs nothing and covers them in advance. |
Deliberately not withheld, and asserted by a test so that adding one is a deliberate act with a visible cost rather than an edit to a list:
PutBucketTagging,PutBucketEncryption,PutBucketNotification. These are bucket-level writes and an earlier draft did withhold them. None gives the caller access it does not already hold — the harm is to the owner’s posture, not to the access boundary — while a tenant handeds3:*onbucket/*and told the bucket is theirs may quite reasonably tag it, set default encryption, or wire up event notifications. Low security gain against a real compatibility cost is the wrong trade for a maintenance release.CreateBucket. It targets a bucket that does not exist yet, so there is nothing to mutate or destroy, and provisioning flows commonly create a tenant’s bucket with that tenant’s ownbucket/*credentials.- The read/list family (
ListBucket,GetBucketLocation, the configuration reads). Breaking these is what got upstream’s own attempt reverted. They wait for a migration-gated release.
Only Allow statements are affected. Deny statements keep the historical resource string, so no bucket lock is ever weakened, and NotResource exclusions keep their full reach.
Monotonicity, and the claim that was wrong twice
All of the above rests on one property: this change may remove permissions and must never add one. That property was asserted twice from reasoning rather than from tests, and was false both times. Recording how is more useful than recording only the final state.
The first attempt let the withheld slash reach the NotResource match as well — and NotResource is an exclusion. An Allow s3:* NotResource bucket/* statement historically did not apply to bucket-level requests on that bucket; matching the exclusion against the bare bucket name made it stop matching, so the Allow it qualified grew, for exactly the writes being protected.
The second attempt fixed that and shipped saying the result was provably monotone. An independent adversarial review of that release produced a counterexample. Withholding the slash does not merely remove a match — it changes which string patterns are matched against, and a pattern can match "mybucket" without ever having matched "mybucket/". A fixed-width wildcard is the clean case:
? matches exactly one character. Against the nine-character "mybucket/" it does not match, so this statement never authorized the bucket-level write. Against the new eight-character "mybucket" it does, so the hardening granted something the buggy matcher refused.
The fix is not another special case. On the protected path the matcher now requires both forms to match — the bare bucket name and the historical "bucket/". The result is an intersection with the historical decision, so it is monotone by construction: there is no pattern it can newly satisfy, and no argument left to get wrong. mybucket* still grants (it matched both all along), mybucket/* is still withheld, and mybucke? is refused exactly as it always was.
Two lessons are worth carrying forward. A correctness fix in an authorization path must never make anything newly allowed — and the only way to know is to test both directions, because the reasoning felt airtight in both cases where it wasn’t. And when a security property is load-bearing, build it out of an operation that cannot violate it rather than out of a case analysis believed to be complete.
Evidence
The property is verified rather than argued. A decision corpus of 27,000 authorization outcomes — 15 resource patterns × 3 buckets × 5 object names × 20 actions × 6 statement forms — was generated against both the pre-hardening baseline and this release and compared entry by entry:
| Transition | Count |
|---|---|
false → true (broadening) |
0 |
true → false (narrowing) |
144 |
| unchanged | 26,856 |
Every one of the 144 narrowed outcomes falls inside the design intent, with nothing outside it: exactly the twelve protected actions; only the three Allow statement forms, with zero transitions for Deny, NotResource-excluded or deny-NotResource forms; only four object-only resource patterns; and only bucket-level requests, with object-level requests entirely untouched. 12 × 4 × 3 = 144, fully accounted for.
Regression coverage exists at both layers. In this repository, twelve matcher tests pin each direction, including an invariant test that every protected action really is bucket-only — ResetBucketReplicationState, despite its name, is an object action and stays out. In the server, three end-to-end tests drive the real handlers at the client, inline-session-policy and S3-router levels; all three fail against the pre-fix build and pass against this one.
What to change
You are affected only if a stored policy grants one of the twelve actions — or s3:* — on a resource pattern containing /, with no bare bucket ARN for the same bucket. The fix is to add the bare ARN alongside the object pattern:
That pairing is the conventional form, is what upstream’s own tests use, and worked before this release as well. Built-in canned policies are unaffected — readwrite, readonly, writeonly and diagnostics all use Resource: "*".
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on, read once at startup, restores the historical matching in full — both the over-granting and the over-blocking. It is a single global switch; per-action scoping is deferred.
Policy Condition-Key Lookup Order
getValuesByKey() previously looked up a policy condition key by its canonical MIME spelling (http.CanonicalHeaderKey) before trying the original name. The map it reads mixes values calculated by the server for the current request (SourceIp, SecureTransport, CurrentTime, username and others, stored under condition-key spellings) with HTTP headers supplied by the request (stored under canonical MIME spellings).
Checking the canonical spelling first allowed a client header to override a value calculated by the server.
For a MinIO server this is a policy bypass. The simplest example is s3:prefix: a Prefix request header could satisfy a home-directory prefix condition while the real ?prefix= query parameter still listed the entire bucket. The same path reached aws:SourceIp, aws:SecureTransport, aws:CurrentTime, aws:EpochTime, aws:username, aws:userid, aws:principaltype, aws:UserAgent, aws:groups, ldap:username, ldap:groups, jwt:groups, s3:versionid, s3:signatureversion, s3:signatureAge, s3:authType and s3:LocationConstraint. Anonymous bucket policies were directly exposed. SigV4 did not prevent the attack because a client can add a header that is not listed in SignedHeaders.
There was a second consequence: when the server stored a value under one spelling and the policy key resolved another, the wrong entry won. s3:object-lock-mode could resolve to the caller’s X-Amz-Object-Lock-Mode header rather than the retention mode the server would actually apply.
The fix reverses the lookup order: match the condition key’s exact name first, then use the canonical spelling only as a fallback for condition keys that genuinely name request headers, such as the s3:x-amz-* family. This ports minio/pkg#226 and adds regression coverage the upstream change did not carry.
At the library’s raw-map layer, if a producer stores one logical field under both the exact condition name and its canonical MIME name, the exact name now wins. This is a library lookup rule, not an S3 wire-protocol rule that says query parameters take precedence. The Silo server first normalizes condition values by their real source. For storage class and upload tagging, where both Header and query forms remain compatible, Header presence wins, including an empty value; query is only the fallback.
LDAP Connection Path
Three defects in connect(). Two were introduced by this fork in b0c08a7 and shipped in v3.6.2 and v3.6.3. Users of either release should upgrade promptly.
StartTLS was skipped when ServerInsecure was enabled. Upstream called StartTLS in an outer block controlled only by ServerStartTLS, so enabling both options created a plaintext connection and then upgraded it. b0c08a7 moved the call into an else branch, making StartTLS unreachable whenever ServerInsecure was true. The connection stayed plaintext and the following bind sent credentials over it. MinIO exposes MINIO_IDENTITY_LDAP_SERVER_INSECURE and MINIO_IDENTITY_LDAP_SERVER_STARTTLS independently and Validate() rejects no combination, so this state was reachable.
This release restores the upstream semantics: the two switches are additive, not mutually exclusive. ServerInsecure disables implicit ldaps://; ServerStartTLS still performs the upgrade. The exposure window is limited to v3.6.2 and v3.6.3.
A Config without a TLS section could panic on the ldaps:// path. After l.TLS.Clone() moved outside the StartTLS branch, ordinary ldaps:// connections also called it. Clone() returns nil for a nil receiver, but the next line assigned ServerName. The MinIO server always supplies TLS settings, but this is a library and mc also consumes it. The code now falls back to an empty tls.Config, matching what DialURL would have built.
StartTLS had no deadline. go-ldap only starts its request timer when requestTimeout > 0, while StartTLS itself has no timeout. A server that completed TCP setup and then stopped responding to the extension request could hold the connect goroutine forever. The timer is now armed before StartTLS.
A failed StartTLS leaked the connection. Inherited from upstream. Dial failures do not return a connection, making StartTLS failure the only connect() path that could return both a connection and an error. Callers only took ownership when the error was nil, leaving a socket behind for every login attempt against a server with a broken upgrade. The failure path now closes the connection and returns nil.
Other Fixes
- certs: file watchers were never stopped.
Manager.AddCertificate()registered twonotify.Watch()calls and stopped neither: if the second failed, the first leaked, and both survived until process exit after the manager closed.Certificate.Watch()andwatchFile()had the same problem. All four paths now usewatchDirSafe(), which returns a stop function invoked on errors andctx.Done(). This ports thecerts/part of minio/pkg#228. On Windows the function replaces filesystem notification with polling rather than using polling only as a failure fallback, so certificate reload can lag by onesymlinkReloadInterval(10 seconds). This fork has no Windows CI; that platform was only cross-compiled. - rng: reader subkeys came from a zeroed local variable.
init()read 32 bytes of entropy intor.tmpbut derived four subkeys from a same-named zeroed local, collapsing four per-block streams into one.Reset()andResetSize()then replayed the previous stream byte for byte. MinIO creates a new reader for eachrandreader.New()call and never resets it, so the practical server impact is limited; warp exposed the defect. This ports minio/pkg#230. - xtime:
DurationimplementedUnmarshalJSONbut notMarshalJSON. Encoding produced an integer number of nanoseconds while decoding unconditionally stripped the first and last byte and expected a quoted string, so neither direction could round-trip. It now encodes usingtime.Duration’s string form. This ports minio/pkg#242.
Compatibility Impact
- Twelve bucket-level write actions are no longer authorized through an object-only resource pattern. See What to change. Object access,
ListBucket,CreateBucket, bucket tagging, default encryption and event notification are all unaffected, as areDenystatements andNotResourceexclusions. - The minimum Go version moves from
1.26.1down to1.25.0. A patch number in thegodirective is a hard minimum for every consumer, not a record of the toolchain used to build the module. The conventional split is a language version on thegoline and a development version on a separatetoolchainline.1.25.0is what the dependency graph actually requires and what upstream declares. CI builds the complete test suite with Go 1.25 underGOTOOLCHAIN=local, so the minimum is proven rather than aspirational. - The JSON wire format of
xtime.Durationchanges from a nanosecond integer to a duration string such as"2h"or"30m". Persisted numeric values can no longer be read back. No such use was found in MinIO ormc: batch job definitions persist as YAML and the msgp path remains int64. - Deployments with both
ServerInsecureandServerStartTLSenabled whose LDAP server does not support StartTLS connected successfully in plaintext on v3.6.2/v3.6.3 and now fail to connect. That is the correct result, but it surfaces during connection rather than configuration validation. DisableServerStartTLSfor such a server. Policy.IsAllowedActionscan disagree with a direct decision for the twelve protected actions. It enumeratesSupportedActions, which includes thes3:*pattern itself, so the returned set can contains3:*— and therefore appear to permit a protected action — while the direct evaluation denies it. Nothing in the server calls it, and Console calls it with an empty bucket name, which never reaches the hardened branch. Recorded rather than changed, because altering a public API’s output in a maintenance release is the larger risk.
Divergence from Upstream v3.11.0
The version number follows upstream’s line and makes no claim of identical content. The measured delta, comparing action-string constants across policy/:
| Count | |
|---|---|
Upstream minio/pkg v3.11.0 |
291 |
silo-pkg v3.11.0 |
270 |
24 actions exist only upstream: six s3:*ObjectAnnotation* actions, five admin: actions (DistJobStatus, Get/SetBucketCompression, two TablesReplication*), and thirteen s3tables: actions covering function CRUD and tagging. These belong to the AIStor vocabulary this fork deliberately does not carry, because the community server does not implement them.
Three actions are named differently on each side. Upstream renamed and split these; this fork retains the earlier names:
silo-pkg v3.11.0 |
upstream minio/pkg v3.11.0 |
|---|---|
s3tables:TagResource |
s3tables:TagTable, s3tables:TagWarehouse |
s3tables:UntagResource |
s3tables:UntagTable, s3tables:UntagWarehouse |
s3tables:ListTagsForResource |
s3tables:ListTagsForTable, s3tables:ListTagsForWarehouse |
A policy naming any of these six action strings therefore validates on exactly one of the two. Nothing in the Silo server, mc or Console references them, so there is no impact inside this ecosystem — but a consumer swapping upstream v3.11.0 for this release should know the vocabulary is not interchangeable.
rng has no arm64 assembly. Upstream added rng/xor_arm64.{go,s} after this fork’s divergence point; this release falls back to the pure-Go xor_noasm.go path on arm64. The result is correct and cross-compiles cleanly, but slower than upstream on that architecture. It is a clean candidate for a future sync, being a pure performance change with no vocabulary entanglement.
Companion Server Behavior
- The condition-key change in this release must be paired with the server changes that reserve internal condition-key names and populate values by semantic source, as noted at the top.
s3:signatureAgeis exposed only after the SigV4 presigned-request verifier calculates it. A client-suppliedx-amz-signature-ageHeader is ignored on every other request type.s3:prefix,s3:delimiterands3:max-keyscome only from query parameters. Content hash, copy source, metadata directive, SSE and object-lock conditions come only from the corresponding headers. TheX-Amz-Content-Sha256query value consumed while verifying a presigned request does not become a policy condition.s3:x-amz-storage-classretains its compatible query form, as do request tags onPutObjectandCreateMultipartUpload. For both fields, Header presence wins and query is used only when the Header is absent.s3:ExistingObjectTag/*comes only from tags loaded from the stored object, so a request’s ownX-Amz-Taggingcan no longer impersonate existing object state.PutObject,CreateMultipartUploadandPutObjectTaggingbinds3:RequestObjectTag/*to the tag input those handlers consume. Other action paths retain the historicalX-Amz-TaggingHeader fallback for compatibility, so treat request-tag conditions as constraints only where the API actually consumes tags.aws:SourceIpis calculated from forwarding headers. Whether it is enforceable depends on the server’s trusted-proxy configuration; see the server’s own release notes forMINIO_API_TRUSTED_PROXIES.
Verification
Everything below was run against the tagged commit, with the working tree clean and the tag pointing at HEAD:
make test— golangci-lint plusgo test -race -tags kqueue ./..., all packages passing.go mod tidy -diffclean;gofmt -lempty;go vet ./...clean.- Cross-compilation for
linux/amd64,linux/arm64,darwin/arm64andwindows/amd64. govulncheck ./...— zero reachable vulnerabilities. One module-level notice remains, GO-2026-5932 inx/crypto/openpgp; that package is unmaintained, has no fixed version, and this repository does not import it.- Resolution from an empty module cache through the public proxy, confirming the release is fetchable as published.
- The 27,000-outcome authorization corpus described above.
Dependencies and Tooling
Dependency updates clear nine reachable findings previously reported by govulncheck: seven x/crypto/ssh issues reached through sftp, GO-2026-6061 in gRPC reached through etcd, and GO-2026-4945 in go-jose reached through oidc.
Five dependencies — minio-go, minio/mux, etcd client/v3, go-oidc and lestrrat-go/jwx — were deliberately not upgraded. MinIO consumes this module through replace, and Minimal Version Selection chooses the highest version in the entire graph, so upgrading them here would also pull the server forward. None has a reported vulnerability requiring that change.
All three workflows previously asked setup-go for a Go version lower than go.mod required and failed on the first Go command; they are now aligned. The linter also fetched an installer from the master branch and reinstalled it on every run. The URL and version are now pinned to v2.11.3, and a matching installed version skips the download.
Changes Deliberately Not Taken from Upstream
- AIStor policy vocabulary (Memory/cortex, Tables/Iceberg, KMS, compression and annotations) and the typed action-constant refactor, none of which the community server implements. This is the source of the action-vocabulary delta.
securityAuditAdmin, which grantsadmin:ExportIAMand therefore exposes every secret key despite what the name suggests.- rng AVX2/NEON assembly. Revisiting the arm64 half is noted above as a future sync candidate.
net.BandwidthBytesPerSec(declared but never read upstream),replicationAdminandDistJobStatusAction.- Two changes initially taken and removed after review: the
consolereadonlybuilt-in policy andGetAllGlobalCertificates. Neither has a consumer. Once operators bind a built-in policy name to users, withdrawing it is particularly unsafe: policy mappings persist by name, and an unresolved name merges into an empty policy that denies everything. Its inheritedadmin:CreateUserDeny also cannot be combined withiamAdmin. The certificate helper inventoried a cache the community server never populates. - Upstream’s golangci-lint
tooldirective, which would add roughly 200 linter dependencies to every downstream consumer’s module graph.
Deliberately Deferred
The general problem in minio/minio#20449 — that bucket/* still reaches ListBucket, GetBucketLocation, the configuration reads, CreateBucket and the three tenant-plausible writes — is not closed here. Closing it means revoking grants real deployments depend on, so it belongs to a release that carries a migration path.
What that release owes operators is more than a longer action list, because no one can enumerate every deployment’s stored policies — which puts a hard ceiling on any approach that picks the protected set by guessing. Three things raise it:
- A startup policy audit that walks stored policies and names each one whose meaning changes, in both the grant and the deny direction. It is read-only and can ship before the enforcement change rather than with it, turning an upgrade surprise into a pre-upgrade checklist.
- A denial that explains itself. When a request is refused because only an object-scoped grant matched, say so and name the compatibility switch. A break an operator can diagnose in thirty seconds costs an order of magnitude less than a silent one.
- A switch with a scope.
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCHis all-or-nothing today, so an operator who needs one action back must reopen the self-escalation path along with it.
Related Commits
- d8b1fa7: fix(policy): settle the bucket-write hardening’s scope and monotonicity
- 1f97549: fix(policy): extend the bucket-write hardening to every bucket-only write
- 3c24ad1: fix(policy): withhold object-only grants from sensitive bucket writes
- da6a22a: docs: say what this fork is and how to depend on it
- 4055b2f: fix(xtime): marshal Duration as a duration string
- 13c26cd: fix(rng): initialize the reader subkeys from the seeded entropy
- 88b37ac: fix(certs): stop file watchers on every exit path
- 74dd36e: fix(ldap): keep StartTLS when ServerInsecure is also set
- 424c3d0: fix(ldap): close the connection when StartTLS fails
- 045d10f: fix(ldap): guard a nil TLS config and arm the StartTLS deadline
- 5c4bf50: fix(policy): prefer the exact key name over the canonical header form
- 802539f: chore(deps): refresh the dependency set and declare the real minimum Go
- e4ec64a: ci: build on the Go version go.mod requires, and prove the declared minimum
- 747d8b8: build: pin the golangci-lint installer and skip a matching install
2.6 - mcli 20260806 Released
Published: 2026-08-06 · Version: RELEASE.2026-08-06T00-00-00Z
Two days after mcli 20260804, this release completes the client’s transition to the Silo identity. It is deliberately a pure rebranding and lockdown release: --version and --help now present the Silo client, every remaining path to MinIO’s SUBNET service is disabled at build time, the embedded vendor encryption key is removed from the diagnostics tooling, and the contribution policy moves to no-CLA with a mandatory DCO sign-off. There are no dependency changes and no protocol changes in this cycle — go.mod is byte-for-byte identical to 20260804 — so the regression surface is confined to text, command gating, and CI.
Behavior changes
Every path that previously reached MinIO SUBNET is now disabled at build time and cannot be re-enabled at runtime:
mcli license register,mcli support upload,mcli support proxy set,mcli support callhome enable, and the online-renewal form ofmcli license update ALIASprint a stable notice — “MinIO SUBNET services (registration, licensing, uploads) are disabled in this Silo build of mc; diagnostics remain available locally.” — and always exit1. Drop these calls from scripts. The file-basedmcli license update ALIAS license.keystill works, with the license parsed offline against the bundled public key.mcli support diag/perf/profile/inspectalways operate in local (airgap) mode: reports, profiles, and inspect archives are written to local files and nothing is uploaded anywhere. The--airgapflag is still accepted for compatibility and is effectively always on. SUBNET registration is no longer a prerequisite for any of them.mcli support callhome disable|status,mcli support proxy show|remove,mcli license info, andmcli license unregisterkeep working — they only read or clear local and server-side configuration.- Fresh configurations no longer seed the
playalias pointing at MinIO’s public demo cluster; the defaults are nowlocal,s3, andgcs. Existing configuration files are never modified, and legacy-config migration still recognizes the historical entries. mcli --versiongains an identity line (“Silo object storage client, based on MinIO technology”) and a second copyright line. The first line’s machine-readable format is unchanged, so scripts parsing it are unaffected.
Major Changes
- Silo identity across the CLI: the client introduces itself as “Silo client for object storage and filesystems”. Roughly 220 help texts were reworked: usage lines that refer to the managed server now say “Silo/MinIO server”, example aliases moved from
myminio/playtomysilo, example LDAP DNs moved todc=example,dc=com, and example tier names toSILOTIER-*. Factual references stay factual: theminiotier type, protocol headers, and third-party interop mentions are untouched. - SUBNET disabled at build time: connectivity is compiled out behind a single guard, and the one HTTP choke point that every SUBNET request funnels through refuses with the stable error above. Command entry points gate early, diagnostics force local mode, and the AGPL license notice shown by
mcli license infono longer carries a commercial-subscription pitch. A dedicated regression suite (cmd/subnet-disabled_test.go) pins all of this, so an upstream merge cannot silently reconnect anything. - Governance — no CLA, DCO required: contributions are accepted inbound=outbound under AGPL-3.0-or-later; contributors keep their copyright, and the maintainers collect no rights beyond the project license. Every commit must carry a
Signed-off-bytrailer, enforced by a new CI workflow that matches the trailer against the commit author’s email and exempts only GitHub-issued bot addresses.CONTRIBUTING.md, the PR template, and both READMEs document the policy, and the code-of-conduct contact now points at the fork’s maintainer. - Dual copyright attribution: runtime output and help now credit both lineages —
Copyright (c) 2015-2025 MinIO, Inc.andCopyright (c) 2025-2026 PGSTY— with source builds computing the end year dynamically.NOTICEstates the fork relationship, and the non-affiliation with MinIO, Inc., explicitly. - Release line renamed to
main: workflow branch filters, documentation, and contributor instructions now targetmain; the legacymasterreferences are gone.
Hardening
- Vendor encryption key removed:
mcli support inspectused to fall back to encrypting its output with an embedded MinIO RSA public key whenever no key was supplied — producing archives only the vendor could decrypt. The embedded key is gone: inspect now relies on the server-generated per-request key that is printed to the caller (or an operator-supplied key), and any encrypted-upload path with no configured recipient fails closed instead of silently borrowing a third-party key. Diagnostic output an operator produces is now always decryptable by that operator. - Brand-policy gate:
buildscripts/check-branding.shruns inmake verifiersand in CI. It fails the build if MinIO-operated endpoints, commercial upsell URLs, the upstream product identity, or any embeddedMII…public key reappear in the command tree — while explicitly allowlisting the preserved compatibility identifiers (environment variables, protocol headers, module path, legacy-migration defaults, and original copyright headers).
Engineering and Delivery
- CI moved to the Node 24 Actions line:
actions/checkoutv7,actions/setup-gov7,goreleaser-actionv7, and the Docker action family — all still pinned to commit SHAs, with dependabot keeping the pins current. - Functional tests run against controlled servers only: the suite defaults to a local server (
localhost:9000) instead of MinIO’s public demo cluster, and CI downloads the pinned SILO server releaseRELEASE.2026-08-04T00-00-00Zfrompgsty/silo, verified by SHA-256, before running the suite. - Zero dependency changes: no module updates this cycle; the 20260804 security baseline (Go 1.26.5, zero known reachable vulnerabilities) carries over unchanged.
- Audited before tagging: the release was gated by an independent adversarial review — a full read of the 245-file diff, brand/compatibility grep sweeps, call-graph verification that no command or flag combination can reach
subnet.min.io/play.min.io/dl.min.io, and smoke tests confirming every disabled path returns its stable error with exit status1.
Compatibility
Everything scripts and integrations depend on is deliberately unchanged: the mc command name and the mcli package/binary name; the ~/.mc / ~/.mcli configuration directories (derived from the invoked name); the github.com/minio/mc module path and all import paths; MC_* environment variables; protocol headers (x-minio-*) and the minio-go SDK user-agent prefix; the minio tier type; the .part.minio transfer suffix; the minio-job Prometheus scrape job name; and the package formats, asset naming, and YYYYMMDDHHMMSS.0.0 version scheme. The client remains fully compatible with MinIO servers and other S3-compatible endpoints.
Known issues
The mcli watch regression flagged in the 20260804 notes is resolved on the server side: the fix shipped in SILO 20260804, and this client’s CI now runs the functional suite — including watch — against exactly that release. Pair mcli with SILO server 20260804 or newer to receive bucket events; older published servers remain affected.
Unfixed upstream defects continue to apply, most seriously minio/mc#5139: mirror --remove --watch can delete a live object from the target when a non-current version of it is removed from the source. Exercise caution combining --remove --watch on versioned buckets.
Related Commits
- 8a883ca: ci: move the branch filters to main and fetch the server from pgsty/silo
- 8c304dd: ci: move the pinned actions onto the Node 24 runtime
- 02b1c11: docs: name the release line main, not master
- 810bbd2: ci: pin the functional-test server to a release whose watch API works
- d145647: fix: disable SUBNET connectivity and licensing upsell paths
- 5061c4f: rebrand: adopt Silo identity in CLI help and examples
- c7f7706: docs: align governance files and package metadata with the fork
- 65c71b2: test: default functional tests to a local server and add brand gate
- c62a64d: fix: credit both MinIO and PGSTY in copyright notices
- d205f88: docs: adopt no-CLA plus DCO contribution policy
- 95326ce: docs: add related-projects table and polish contribution wording
- d2c0db7: fix: remove the vendor encryption key and close the proxy-set path
- 0c6704d: fix: repair a link and help text damaged by the brand sweep
2.7 - Silo 20260806 Released
Version: RELEASE.2026-08-06T00-00-00Z · Commit: 3be10fcc1a44f6620ded0bd303461f9d688cca23
SILO 20260806 is the first release published under the Silo name. The previous release, 20260804, was the last one delivered as pgsty/minio; this release completes the cutover to github.com/pgsty/silo and renames every delivery surface — binary, packages, container images, systemd unit, Helm chart — while deliberately preserving every wire and configuration surface a MinIO deployment depends on. On top of the rename it adds native health checking (silo healthcheck), a single-binary distroless container image pilot, complete license-compliance materials in every artifact, and a release pipeline gated on compatibility snapshots and build provenance.
The release covers 28 commits after RELEASE.2026-08-04T00-00-00Z, changing 396 files with 27,188 insertions and 19,561 deletions. It passed a six-phase pre-release acceptance, including a real four-node TLS cluster migration from MinIO to Silo — with byte-verified data integrity, maintenance-gated rolling restarts, fault injection, and a full rollback rehearsal.
Highlights
- The rebrand is complete, and compatibility is the contract. Repository, binary (
/usr/bin/silo), packages (silorpm/deb/apk), images (docker.io/pgsty/silo), and service (silo.service) are renamed; the S3 and admin APIs,/minio/*routes,MINIO_*environment variables,x-minio-*headers, on-disk.minio.sysformat, and Go module paths are all preserved and frozen by a CI compatibility guard. - Native health checking:
silo healthcheck [live|ready|cluster|cluster-read]probes the server’s own health API with correct exit codes, decoded quorum diagnostics, TLS auto-detection, and a--maintenancepre-drain gate — no shell,curl, ormcrequired in the container. - Distroless image pilot:
pgsty/silo:<RELEASE>-distrolessships exactly one program — thesilobinary — ongcr.io/distroless/static, with an exec-formHEALTHCHECKbaked in and/datacreated writable in the image layer. - The classic image does not change behavior: same entrypoint, same bundled tools,
mc ready localkeeps working, and noHEALTHCHECKwas added to it. It now bundlesmcli20260806. - Compliance completed: LICENSE and NOTICE ship in every package and image, CREDITS is regenerated from the actually-linked module set (291 modules) and guarded in CI, and the project adopts a no-CLA, DCO-based contribution policy.
- Components refreshed: embedded SILO Console 2.1.1,
silo-pkg3.11.0,mcli20260806, Go 1.26.5. - Provenance-gated releases: container images are built only from published, checksum- and attestation-verified release archives; image SBOMs and provenance attestations now cover the distroless variant too.
The rename
What changed, and what deliberately did not:
| Renamed (delivery surface) | Preserved (compatibility surface) |
|---|---|
Repository: github.com/pgsty/silo (main branch) |
S3 API, admin API, and request signing behavior |
Binary: /usr/bin/silo |
/minio/* routes, including /minio/health/* and metrics |
Packages: silo-*.rpm, silo_*.deb, silo_*.apk |
MINIO_* environment variables and x-minio-* headers |
Images: docker.io/pgsty/silo (+ -distroless) |
On-disk format (.minio.sys), erasure coding, versioning |
Unit: silo.service (conflicts with, and supersedes, minio.service) |
Go module and import paths (github.com/minio/...) |
Default config dir: ~/.silo (falls back to an existing ~/.minio) |
mc compatibility alias for the bundled mcli |
The server presents its own identity — silo --version reports the AGPL-3.0 license, MinIO’s 2015-2025 copyright, PGSTY’s modification copyright, and the “based on MinIO technology” attribution — and every inherited connection to MinIO-operated services (the update feed and its signing key, SUBNET, telemetry) is severed rather than redirected. The container entrypoint translates the legacy minio argv token, so docker run pgsty/silo minio server /data keeps working.
A snapshot-based rebrand guard runs in CI: it fails on any drift, in either direction, across 334 route literals, 437 environment tokens, 84 headers, and 9,014 exported symbols.
Native health checking
The server binary can now probe its own health endpoints, which makes container health checks possible without any second binary — and is what the distroless image relies on:
- The check vocabulary maps 1:1 onto
/minio/health/<path>;live(the default) answers “is this process serving,”readyadds KMS/etcd reachability when configured, and theclusterpair evaluates write/read quorum across every erasure set. - Exit codes are
0(healthy) and1(anything else) — never the Docker-reserved2. One diagnostic line decodes the server’sx-minio-server-statusand quorum headers fordocker inspect;--jsonemits a machine-readable verdict. - The probe target is derived the way the server derives its own listen address:
--address/MINIO_ADDRESS, with HTTPS auto-detected frompublic.crt+private.keyin the certs directory, or overridden wholesale with--url/MINIO_HEALTHCHECK_URL. The environment form exists because a probe process cannot see the server’s command line — if the server’s address or TLS comes from CLI arguments, one environment variable redirects the baked-in probe. silo healthcheck --maintenance clusteranswers the pre-drain question: exit0means the node can be taken down without losing HA; HTTP 412 (exit1) means it cannot.- Certificate verification is skipped, matching the kubelet’s documented behavior for HTTPS probes, and the transport ignores
HTTP_PROXYso loopback probes never route through a proxy.
Kubernetes needs none of this — kubelet httpGet probes hit /minio/health/live and /minio/health/ready from outside the container — and the cluster checks should stay out of per-container probes: they reflect cluster-wide quorum, not one process. The full design rationale, including verified endpoint semantics, is recorded in the health-check design note.
Distroless image pilot
Alongside the classic image, this release publishes a distroless variant: pgsty/silo:RELEASE.2026-08-06T00-00-00Z-distroless, plus a rolling distroless tag.
- Base is
gcr.io/distroless/static-debian12: CA certificates, tzdata,/tmp, and an/etc/passwdwith anonroot(65532) entry — no shell, no package manager, no libc. On top of it, exactly one program:/usr/bin/silo(plus the license set under/licenses/). The image is 128 MB versus the classic 199 MB. - The binary is the
ENTRYPOINT; an exec-formHEALTHCHECKrunningsilo healthcheck readyis baked in (interval 30s, timeout 10s, start-period 2m, retries 3), so Compose users get workingdepends_on: condition: service_healthywith zero configuration. /datais created in the image layer, world-writable — there is no entrypoint left to repair volume ownership at runtime, and this is what makes every privilege mode work,--userincluded. This fixes, for the distroless variant, the non-root failure documented in #55.- Not supported in this variant: the deprecated
MINIO_USERNAME/MINIO_GROUPNAMEprivilege-drop path (use--useror KubernetesrunAsUser),docker exec <c> shdebugging (use ephemeral-container tooling), and in-imagemc(use the releasedmclior the client image). - TLS: mount certificates at
/tmp/.silo/certs(the container’s default certs directory) and both the server and the baked-in probe derive HTTPS from the same location; for CLI-configured servers, setMINIO_HEALTHCHECK_URL.
The classic image remains the default and is unchanged. If the pilot proves out, the distroless variant becomes the recommended image later; the decision record lives in the design note above.
Container images
The classic image was diffed field by field against pgsty/minio:RELEASE.2026-08-04T00-00-00Z: entrypoint, exposed ports, volumes, working directory, user, and (absent) health-check configuration are identical. Exactly three differences exist, all deliberate: Cmd is ["silo"] instead of ["minio"], the upstream update-verification key variable MINIO_UPDATE_MINISIGN_PUBKEY is removed (updates through upstream channels are permanently disabled), and HOME=/tmp is declared to match the entrypoint’s writable-home guarantee.
The bundled client is upgraded to mcli RELEASE.2026-08-06T00-00-00Z (with the mc alias preserved), pinned by per-architecture SHA-256 digests and verified against the published checksums at build time. Interoperability of the released mcli 20260806 against this server — multipart, versioning, presigned URLs, metadata/tags, user and policy administration — was verified as part of release acceptance.
Helm chart
The chart ships as silo 7.0.1, preserving rendered resource identity with the legacy chart across a simulated upgrade (verified by the migration guard over 7 rendered resources). Its default image tag now points at this release — docker.io/pgsty/silo is a fresh repository, so the inherited default could never have pulled. The chart still ships no liveness/readiness/startup probes; adding them is planned, and documented, in the design note’s follow-up phase.
Packaging and migration
RPM, DEB, and APK packages install exactly six files: /usr/bin/silo, silo.service, a sysusers definition (creating the silo system user), /etc/default/silo (marked config/noreplace), LICENSE, and NOTICE. RPMs are GPG-signed with the PGSTY maintainer key (9592A7BC 7A682E73 33376E09 E7935D8D B9BD8B20). RPM and DEB now carry a unified, PGDG-style 1PGSTY release segment — silo-<version>-1PGSTY.<arch>.rpm and silo_<version>-1PGSTY_<arch>.deb — replacing the inherited bare -1 on RPM and the missing revision on DEB; APK names stay bare because Alpine pkgrel admits only -r<integer>.
silo.service is designed for takeover: Type=notify (readiness is signaled by the server itself), Conflicts=minio.service + After=minio.service (starting Silo stops a running MinIO unit), and two environment files — /etc/default/minio is read first and /etc/default/silo overrides it — so an existing MinIO configuration is inherited without editing. For existing deployments whose data is owned by the minio user, the documented drop-in keeps ownership untouched:
Distributed migrations must switch all nodes together.
Cluster bootstrap verifies that every node runs the same binary (by checksum). A mixed cluster — some nodes on Silo, some still on MinIO — does not form: the new node stays in activating, logging Expected Silo binary checksum ... seen: ... and Waiting for at least 1 remote servers with valid configuration, indefinitely. Stop MinIO on all nodes, then start Silo on all nodes (near-simultaneously). Once every node runs Silo, rolling restarts work normally — gate each one with silo healthcheck --maintenance cluster.
Migration troubleshooting, from the acceptance run: if Silo starts as the packaged silo user against a deployment whose TLS certificates live under the minio user’s home, it fails with HTTPS specified in endpoints, but no TLS certificate is found and restart-loops until the systemd start limit — the legacy-user drop-in above is the fix. Keep the MinIO package and unit installed (disabled) during the migration window: the rollback path — stop Silo, start MinIO — was rehearsed and reads all data written during the Silo window, because the migration touches neither data ownership nor format.
Components and dependencies
- SILO Console 2.1.1 — the embedded console, selected from
pgsty/silo-consolewhile preserving thegithub.com/minio/consoleimport path. silo-pkg3.11.0 — retains the policy/LDAP/certificate fixes including the LDAP-over-TLS repair tracked in #15.mcli20260806 — bundled in the image and released separately; see its release notes.- Go 1.26.5 — toolchain unchanged from 20260804.
Build, CI, and release pipeline
- Compatibility as a CI gate: the rebrand guard snapshots routes, environment tokens, headers, metrics, storage/policy identifiers, and exported symbols, and fails on any unreviewed drift; companion scripts assert the delivery surface (binary path, unit contents, image layout) and that no live upstream endpoint remains in runtime code.
- Release-image gate: every release-pipeline run builds both container images and asserts, among others: the distroless
HEALTHCHECKsurvives into the image config (it is a Docker extension outside the OCI spec),/dataships world-writable, no shell and no/usr/bin/minioexist, Docker’s health state turns healthy from the baked probe alone, and SIGTERM still stops the server gracefully as root and as--user 1001:1001. - Provenance chain: images are built from the published release archives after checksum verification and
gh attestation verifyagainst the exact tag; per-architecture SBOMs and provenance attestations are pushed for the classic and distroless images; the distroless health-check gate runs before the multi-arch manifests are promoted. - Workflow runtime moved to Node 24 across CI actions.
Compatibility and upgrade notes
- Package upgrades are a takeover, not an in-place update. Install
silo, keep/etc/default/minioas is (it is inherited), enablesilo.service; starting it stopsminio.servicevia the conflict relation. Data is untouched. - Keep data ownership stable with the legacy-user drop-in above; do not chown storage or move certificates during migration.
- Distributed clusters: full-stop switchover only. See the warning above — mixed Silo/MinIO nodes do not form a cluster.
- Container users: the image is now
docker.io/pgsty/silo;docker.io/pgsty/miniostays frozen at 20260804 as an archive. The classic image’s behavior is unchanged — includingmc ready localhealth checks — and the distroless variant is strictly opt-in. - Distroless differences are deliberate: no shell, no in-image
mc, noMINIO_USERNAMEpath; health is native; servers configured via CLI arguments needMINIO_HEALTHCHECK_URLfor the baked-in probe. - Helm users: chart 7.0.1’s defaults now pull this release; override
image.tagexplicitly if you pin versions. - Known and unchanged: the classic image still does not create
/datain the layer, so fully non-rootdocker runagainst a Docker-managed volume fails as before (#55, fixed in the distroless variant); the inherited Postgres/MySQL legacy notification-migration limitation from the 20260804 notes still applies (#53). - Pair with
mcli20260806 for the client side; older clients continue to work over the unchanged wire protocol.
Verification
This release was verified in stages, each with recorded evidence:
- unit and end-to-end matrices for the health-check command: target derivation and precedence (flag/env/derived), real-TLS auto-detection, exit-code contract, JSON schema, timeout bounds, usage errors;
- cluster-semantics verification on a four-node cluster: with 2 of 4 nodes stopped,
clusterreports 503 withwrite-quorum=5whilecluster-readandlivestay 200 — the write/read quorum split observed live, matching the erasure math; - image acceptance: the classic image diffed field-by-field against the 20260804 baseline; the distroless image asserted down to file inventory, exact health-check configuration, and root/non-root/TLS/env-override runtime scenarios;
- an adversarial model-based code review of the new code, with every confirmed finding fixed and re-verified;
- a six-phase pre-release acceptance concluding in a real migration: a Pigsty-deployed four-node TLS MinIO 20260804 cluster (16 drives, EC:4) was migrated to Silo via the packaged takeover path — reference data (multipart, versioned, tagged objects) read back byte-identical, four maintenance-gated rolling restarts,
kill -9fault injection with the load balancer serving 23/24 continuous IO rounds (the only failure in the kill second), Prometheus metrics continuity, and a full rollback to MinIO and back, proving the migration reversible.
Validation boundaries
Not proven by this release and not to be inferred: external LDAP/OIDC/KMS/etcd services (the only case where ready diverges from live was not exercised against a live KMS); amd64 packages were cross-built and payload-checked but not installed on a physical x86-64 host; the renamed Docker publish workflow (including the new SBOM/attestation lanes) has its first production run at this release’s publication; Windows and Intel macOS were not tested.
Artifacts
- GitHub release
RELEASE.2026-08-06T00-00-00Zatpgsty/silo, with checksummed platform archives, provenance attestations, and RPM/DEB/APK packages (GPG-signed RPMs); docker.io/pgsty/silo:RELEASE.2026-08-06T00-00-00Zandlatest;docker.io/pgsty/silo:RELEASE.2026-08-06T00-00-00Z-distrolessanddistroless— published on demand from the finished release;- companion releases:
mcli20260806,silo-pkg3.11.0, embedded SILO Console 2.1.1; - design record: Native Health Checks and the Distroless Image.
Selected changes
15def34dc,77bdc4c0c: drop upstream delivery residue; present Silo identity and close inherited upstream services15ab10833: rename the delivery artifacts to silo and complete the package payload30749911b: ship the silo binary in the image and translate the legacy argv commande071bb77e: replace the minio chart with a silo chart that preserves identitybd8df5166: gate the rebrand on compatibility, packaging, and provenance evidence6613c2a3c: pin the external test fixtures and run the suites against the silo binaryfd2ca1c6d,c46b16ec6,c47733abc,f1c77d5a2: cut over to pgsty/silo and main; document the archived branch6740e6978: move the workflow actions onto the Node 24 runtimeb57275be3: adopt the no-CLA plus DCO policy and fix copyright terms62717d7bf,a6d6d9b02: update the embedded Console to 2.1.0, then 2.1.16bd9cf77e: regenerate CREDITS from the linked module set and guard it in CI219670d31: ship LICENSE and NOTICE in every package and image2ff594f4b: add the nativesilo healthchecksubcommand4c34d2309: add the distroless image variant as a pilotb6d47b739,9462cce16: harden both per adversarial review; lint cleanup16b78eb4e: bundle mcli 20260806 and point the Helm defaults at this release062a91bee: pin the CREDITS module closure to the shipped linux target467931455: unify the rpm and deb release segment as1PGSTYb14ea22aa: match checksum manifest entries exactly in the image publish lane3be10fcc1: add a manual finalize lane refreshing SBOMs and checksums for signed Draft packages
Acknowledgments
Four contributors have code merged into this fork, and the Git history carries their authorship: @ZouhairCharef patched CVE-2026-34986 in go-jose (#18), @mfredenhagen patched CVE-2026-39883 in OpenTelemetry (#19), @pinginfo implemented Flush on trackingResponseWriter to repair bucket notification streaming (#34), and @waterkip repointed the documentation links to the Silo portal (#41).
A first release under a new name is also the right moment to thank everyone who has filed issues against this fork — bug reports, compatibility findings, and proposals alike, resolved and still open:
@mosesdd (#1), @Xavier-777 (#2, #17), @jiadzh (#3), @TLINDEN (#4), @AntonOfTheWoods (#5), @zylpsrs (#6), @nsanitate (#7), @makinikm (#9), @magicxor (#10), @spaceg00se-r (#11, #14), @heroes1412 (#13), @vampywiz17 (#15), @davinkevin (#20), @chalukyaj (#30), @cbornet (#31, #32), @jvasile (#33), @Kesavaambati (#35), @redfoxfox (#38), @kuldeep-link11 (#39, #40), @meesudzu (#42), @pmezhuev (#43), and @kh0mka (#51).
Several of this release’s headline items trace directly back to those reports: the bundled-client guarantee to #4 and #9, the LDAP-over-TLS repair to #15, the completed package payload to #33, GPG-signed RPMs to #43, the migration guide to #42, and the distroless /data fix to #55.
Pull requests still in flight deserve a mention too. @davinkevin’s distroless image PR (#21) anticipated this release’s pilot months in advance — the shipped variant supersedes that PR with the native health check built in, but the direction was proposed there first. Conformance PRs from @magicxor (#12) and @ycjlin (#37) are queued for review immediately after this release.
Everyone who has contributed to this fork is recorded in CONTRIBUTORS.md, which is now the project’s attribution record — GitHub generates no contributor graph for forks.
2.8 - Silo 20260804 Released
Version: RELEASE.2026-08-04T00-00-00Z · Commit: d88f46ccee345a9c2fabe2d221d9a9e56bc11aec
SILO 20260804 is a security, correctness, and release-engineering update to the pgsty/minio community fork. It completes the internode storage-containment work begun with CVE-2026-42600, prevents request-controlled values from impersonating server-calculated S3/IAM policy conditions, restores streaming flush behavior, fixes several multipart and versioning edge cases, hardens notification configuration migration, moves the build baseline to Go 1.26.5, and connects the server to the SILO-maintained Console, shared package, and mcli releases. The release pipeline was rebuilt to produce reproducible binaries and GPG-signed packages.
The release covers 50 commits after the pre-2026-06-18 baseline, changing 155 files with 9,241 insertions and 981 deletions. Every change was reviewed against the tagged commit and verified on macOS ARM64 and Linux AMD64, with GitHub CI green on the released HEAD.
Highlights
- Internode containment completed: validates storage-REST message bodies, storage Grid frames, and peer-S3 Grid requests at the storage boundary, closing the remaining path, volume, erasure-metadata, panic, and unbounded-allocation defects left after removing
ReadMultiple. - S3/IAM decisions now use effective values: client input can no longer shadow internal condition values; request tags and existing-object tags are separated;
s3:signatureAgeis confined to verified presigned requests; ands3:versionidfollows the version the server actually acts on. - Bucket and object resources are separated: twelve sensitive bucket-level writes are no longer authorized through an object-only
bucket/*resource pattern. A documented compatibility switch is available for migration. - Multipart compatibility and correctness improved: full-object checksum completion works without per-part checksums when the protocol permits it, zero-length multipart checksums are preserved, and duplicate part numbers are rejected instead of assembling duplicated data.
- Streaming reliability restored:
trackingResponseWriternow implementsFlushcorrectly and records implicit HTTP 200 responses, repairingmcli watch, bucket-notification listeners, and S3 Select keep-alives affected by the inherited regression documented in the 20260618 release. - Notification configuration hardened: NATS and AMQP keys used by parsers and legacy migration are registered and round-trip correctly; libpq connection parameters are quoted safely; invalid-key errors no longer echo secret values.
- Reproducible, signed release pipeline: binaries no longer embed the build machine’s paths, packages install under the canonical systemd path, and RPMs are GPG-signed. The container entrypoint now shuts down gracefully on every privilege path.
- Release baseline refreshed: Go 1.26.5,
klauspost/compress1.18.7, Apache Thrift 0.24.0, SILO Console 2.0.0,silo-pkg3.11.0, andmcli20260804.
Security Hardening
Internode storage and Grid containment — SN-2026-002
Removing the obsolete ReadMultiple endpoint in 20260618 closed one reachable path but did not close the underlying defect class. Storage-REST request bodies and Grid RPC frames do not pass through the HTTP query-validation middleware, and peer-S3 RPCs can bypass the storage-REST wrapper entirely.
This release moves containment to the storage boundary and validates every caller-controlled path, volume, erasure parameter, part size, shard length, and allocation length before use. The fixes include:
- reject traversal on both path and volume axes, including Windows volume-root aliases;
- cover peer-S3 bucket RPCs that reach drives without the storage-REST wrapper;
- reject zero or unusable data/parity/block-size combinations before shard arithmetic;
- reject negative part sizes and truncated shards instead of reporting them healthy;
- cap storage-REST
ReadFileallocations at 5 GiB; - bound other allocations derived from internode declarations;
- contain panics in deadline-bounded storage work without blocking the caller;
- preserve
ReadPartserrors across keep-alive responses and avoid the empty-part trace panic.
These routes require cluster-root or internode credentials and are registered only in distributed-erasure deployments. Single-node S3 behavior is unchanged. See Internode Path Containment Audit for the protocol-surface analysis.
Effective policy-condition values — SN-2026-003
The policy condition map historically mixed values calculated by the server with raw request entries. A client-controlled spelling could therefore shadow or synthesize an internal condition value. SILO 20260804 pairs silo-pkg 3.11.0’s exact-key lookup rule with server-side source normalization:
- internal condition names cannot be supplied as arbitrary client values;
s3:prefix,s3:delimiter, ands3:max-keyscome from their effective query inputs;- header-backed
x-amz-*conditions do not accept unrelated query substitutes; - when storage class or upload tagging supports both forms, an explicitly present Header wins, including an empty Header;
s3:ExistingObjectTag/*comes only from stored object metadata;s3:RequestObjectTag/*is bound to the tag input consumed by the relevant operation;s3:signatureAgeis exposed only after verified SigV4 presigned authentication calculates it;s3:versionidis absent when no version is named and is rebound perDeleteObjectsentry to the effective resolved version.
The version-ID behavior closes the fail-open trap that a superficial “omit empty values” fix would have created for Multi-Delete. See Absent Is Not Empty.
Bucket/object resource boundary — SN-2026-004
The IAM matcher used to append a slash to a bucket-level request, allowing an object-only resource such as arn:aws:s3:::bucket/* to authorize selected bucket-level operations. This release withholds twelve sensitive writes from that pattern on Allow statements:
PutBucketPolicy, DeleteBucketPolicy, PutBucketObjectLockConfiguration, PutBucketVersioning, PutReplicationConfiguration, PutBucketLifecycle, DeleteBucket, ForceDeleteBucket, PutBucketCors, DeleteBucketCors, PutBucketQOS, and PutInventoryConfiguration.
Deny and NotResource behavior is unchanged. Read/list operations, CreateBucket, bucket tagging, default encryption, and notification configuration remain compatible. Built-in policies use Resource: "*" and are not affected.
Policy migration required for custom bucket grants
If a custom policy grants one of the twelve actions — often through s3:* — using only arn:aws:s3:::bucket/*, add the bare bucket ARN:
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on restores the historical matcher while policies are migrated. It also restores the historical over-grant, so use it only as a temporary rollback control.
Trusted client-address boundary
MINIO_API_TRUSTED_PROXIES provides an enforceable, opt-in boundary for aws:SourceIp, audit remotehost, event notification Host, and the client address shown by mcli admin trace:
- set it to an address/CIDR list to trust forwarding headers only from those peers and walk forwarding chains from right to left;
- set it to
noneto ignore all forwarding headers; - leave it unset to preserve historical behavior exactly.
The old _MINIO_API_XFF_HEADER=off switch still suppresses only X-Forwarded-For; it does not protect against X-Real-IP or RFC 7239 Forwarded. If IP-based policy is part of your security boundary, configure trusted proxies explicitly and prevent direct access to the S3 API port. Multi-node deployments should allow their own node addresses. See Client Source Address Trust.
S3 and Storage Correctness
Multipart upload
- CompleteMultipartUpload accepts the S3 full-object checksum mode when the completed request supplies no per-part checksums and the upload metadata does not require them.
- The checksum of a zero-length multipart object is retained instead of being discarded as empty metadata.
- Part numbers must be strictly increasing. Duplicate entries such as
[1,1]now returnInvalidPartOrderinstead of consuming the upload and assembling the same part twice. Legal part lists with gaps or a non-1 start remain accepted. See Duplicate Part Numbers.
Object reads and buffer ownership
- erasure reads again pool buffers only where ownership permits reuse;
- update downloads return caller-owned buffers instead of exposing data that can be overwritten after return;
- the old HTTP streaming helpers orphaned by
ReadMultipleremoval are deleted after reference and platform-tag checks.
HTTP response tracking and S3 Select
trackingResponseWriter.Flush()delegates to the underlying flusher and commits the response state correctly;- the first implicit write records HTTP 200, preserving audit and metric accuracy;
- S3 Select tests no longer race a client parser against response-body ownership;
- CSV, JSON, and Parquet selection paths remain covered, including range/error and keep-alive behavior.
The inherited silent-flush regression called out in SILO 20260618 is therefore fixed in this release.
IAM, Versioning, and Audit Behavior
- DeleteObject and each entry in DeleteObjects evaluate
s3:versionidagainst the effective version selected by the server. - Request tags can no longer impersonate existing-object tags during policy evaluation.
- The
merrstag is restored when dangling-object deletion records are emitted, preserving the intended audit classification. - Bucket-policy and IAM paths share the hardened condition-source rules while retaining their established S3 routing and error behavior.
Notification Configuration
- registers the NATS
user_credentials,nkey_seed, andtls_handshake_firstkeys read by the parser; - separates the legacy NATS environment-variable spelling from the stored config key;
- repairs NATS migration round trips and the AMQP
immediate/internalmapping; - adds a mechanical audit that compares keys read and written by notification code with each subsystem’s registered schema;
- quotes libpq connection-string parameters so whitespace, quotes, and backslashes retain their intended value;
- prevents invalid-key diagnostics from echoing secret values.
See Notify Keyspace Registration.
Known legacy migration limitation
The inherited Postgres and MySQL legacy migration functions still write the unregistered host, port, username, password, and database fields. A migrated configuration can therefore fail validation on the next load, and notification target loading is fail-fast across subsystems. This predates 20260804, but upgrades from pre-connection-string database notification configurations must be reviewed and converted before restart. The stored password field may contain a plaintext database password.
Components and Dependencies
- Go 1.26.5: includes security fixes in
crypto/tlsandosplus compiler, runtime, networking, and syscall corrections. klauspost/compress1.18.7: refreshes the compression stack used by object and archive paths.- Apache Thrift 0.24.0: updates the dependency compiled through Parquet support.
- go-systemd 22.6.0: deliberately retained instead of 22.7.0 because the later version introduced a NetBSD clock dependency incompatible with the supported cross-build matrix.
- SILO Console 2.0.0: the embedded console is selected from
pgsty/silo-consolewhile preserving the compatiblegithub.com/minio/consoleimport path. silo-pkg3.11.0: provides the companion policy, LDAP, certificate, RNG, and time-format fixes while preserving thegithub.com/minio/pkg/v3module path.mcli20260804: the embedded client comes frompgsty/mc; release images expose it asmcliand keep themccompatibility alias.
See the companion release notes for silo-pkg 3.11.0, mcli 20260804, and SILO Console 2.0.0.
Build, CI, and Packaging
This release rebuilt the release pipeline for reproducibility and supply-chain integrity:
- Graceful container shutdown on every path. The entrypoint’s custom UID/GID branches now
execinto the server so it runs as PID 1 and receivesSIGTERMdirectly; previously those branches left an intermediate shell as PID 1 and the server was killed at the container stop timeout. A CI smoke test builds the release runtime image and asserts graceful shutdown on both the default and drop-privilege paths. - Reproducible binaries. Release binaries no longer embed the build machine’s
GOPATH/GOROOT, so-trimpathholds and a third party rebuilding the tag gets matching bytes. The published Linux binary contains no build-host path. - Hardened release workflow. The release tag is passed through the environment and whitelisted rather than spliced into the shell, the build is checked out at the tag being released, and an untracked shadow GoReleaser config that could publish or move
latestout of band was removed. - Honest gates. CI gates build, vet, unit tests, lint, generation drift, race tests, and cross-compilation; the cross-compile matrix is aligned to the exact set of published targets; and the lint and dependency-install steps now fail on real errors instead of masking them.
- Signed, canonical packages. RPM, DEB, and APK packages are produced with nFPM under the PGSTY identity, the systemd unit installs at
/usr/lib/systemd/system/minio.servicewithType=notify, and RPMs are GPG-signed offline with the PGSTY maintainer key (fingerprint9592A7BC 7A682E73 33376E09 E7935D8D B9BD8B20). - Release and container publication remain separate gates. GoReleaser produces the platform archives, checksums, and packages; the multi-architecture image is published on demand from the finished release. A local snapshot does not prove a public release or image exists.
Compatibility and Upgrade Notes
- Keep every node on one release during a cluster rollout. Internode validation changed across storage-REST and Grid surfaces; mixed binaries were not production-tested.
- Audit custom IAM policies. Add the bare bucket ARN for the twelve protected bucket writes. Use
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=ononly as a temporary migration control. - Configure client-address trust deliberately. If
aws:SourceIpor audit attribution matters, setMINIO_API_TRUSTED_PROXIESand close direct network paths around the proxy. - Review legacy database notification settings. Convert Postgres/MySQL host/user/password fields to the supported connection-string format before restart.
- Expect duplicate multipart completion entries to fail. Clients sending the same part number more than once now receive
InvalidPartOrderinstead of a corrupted successful object. - Use the matching
mcli. The 20260804 client disables self-update and must be upgraded through packages or GitHub Releases;mcli updateremains as a compatibility command but exits non-zero. - RPM users can enable signature verification. Packages are signed with the maintainer key above; import it before enabling
gpgcheckfor the SILO packages.
Verification
Changes were reviewed against the tagged commit and re-verified rather than trusted from prior reports:
git diff --check, gofmt, module verification, and YAML/shell syntax;go build ./...,go vet ./..., project lint, andgovulncheck ./...;- full
go test ./..., the complete race suite, and repeated race tests over storage, policy, notification, HTTP tracking, and S3 Select changes; - generator idempotence plus deliberate stale-source and untracked-output counterexamples;
- cross-compilation across every published target;
- Linux AMD64 native full tests, targeted race tests, live S3/
mclismoke tests (create/upload/download/copy, range, versioning, delete markers, health checks, graceful shutdown, restart persistence), and systemd notify behavior; - release-artifact verification: GitHub CI green on the released HEAD, reproducible binaries with no build-host path, the systemd unit installed at
/usr/lib/systemd/system, and RPM signatures validated withrpmkeys --checksig.
govulncheck found no vulnerability reachable from the Server or mcli code. One module-level notice remains for the unmaintained golang.org/x/crypto/openpgp package (GO-2026-5932); that package is not imported into these binaries.
Validation boundaries
The following were not proven by this release and must not be inferred from cross-compilation or unit tests:
- native Windows execution and Windows filesystem semantics;
- Intel macOS and physical Linux ARM64 hosts;
- a production multi-node rolling upgrade, site replication, or lifecycle expiration run;
- real reverse-proxy chains and direct-ingress isolation;
- external LDAP, OIDC, KMS, STS, Postgres, MySQL, NATS, and AMQP services;
- installing and upgrading the signed package under a real systemd host.
Artifacts
- GitHub release
RELEASE.2026-08-04T00-00-00Zwith checksummed platform archives for Linux, Darwin, and Windows on amd64 and arm64; - RPM, DEB, and APK packages under the PGSTY identity, with GPG-signed RPMs;
docker.io/pgsty/minio:RELEASE.2026-08-04T00-00-00Zand the release-selectedlatesttag, published on demand from the release;- matching SILO Console 2.0.0,
silo-pkg3.11.0, andmcli20260804 references.
Selected Changes
ca7baa670,80e8eaa42,b6f70ab08: validate internode paths, erasure metadata, and allocation sizesa36fd8fff: contain panics in deadline-bounded storage work2f55347f7: bind S3/IAM policy conditions to effective request values744a9dcd7: binds3:versionidto the effective object version97b7d2804: enforce the bucket/object resource boundaryfe6dc4780: add the trusted-proxy client-address boundary22c1e41fd: reject duplicate multipart part numbersc8590413f,3e14733f1: restore full-object and zero-length multipart checksum behavior8069a32ac,65795ee1f: restore response commit and streaming flush semantics162ded343,0c14d8151: repair notification key registration and libpq quoting924717926,89d346bf5: restore safe buffer pooling and returned-buffer ownership3b8a55dee: exec into the dropped-privilege process so signals reach the server2ca4971d9: stop stamping the build machine’s paths into the binary4c185d5a6,e064b5555: harden the release workflow and remove the shadow configaa5139369: install the systemd unit under/usr/lib11d79fddc,ca674a696,021110b45,d88f46cce: gate build, vet, tests, lint, generation, race, and cross-compilation, and smoke-test the release image
2.9 - mcli 20260804 Released
Published: 2026-08-04 · Version: RELEASE.2026-08-04T00-00-00Z
This is the first release of the pgsty/mc community fork since 20260417. It fixes a credential leak in debug logging, severs every remaining connection between the client and upstream release channels, moves containers and packages onto artifacts this fork builds itself, and migrates packaging from MinIO’s pkger to standard nFPM — with GPG-signed RPMs for the first time.
Upstream minio/mc was archived in July 2026. Its final commit, 77f82e18, is exactly this fork’s base, and upstream never cut a release containing it — so this build is strictly newer than any official mc binary ever published.
Behavior change
mcli update self-update is disabled in this fork. The command remains for script compatibility and still accepts its original arguments, but it no longer contacts the network or replaces its own binary; it prints an explicit notice and always exits with status 1. Upstream mc update exited 0 when already up to date, so drop the call from any script that treats a non-zero exit as failure. Upgrade through the Pigsty package repository or GitHub Releases.
The automatic version check that ran against upstream release feeds on every invocation has also been removed entirely. The MC_UPDATE and MINIO_UPDATE environment variables are no longer consulted.
Major Changes
- Self-update disabled, upstream release channels severed: the
minio/selfupdateandaead.dev/minisigndependencies and all binary-replacement logic are gone, along with the update notifier and the FIPS/non-FIPS update paths. Theupdatecommand survives as a compatibility shell, and the runtime helpers (Docker / DCOS / Kubernetes / source-build detection) moved to a dedicatedcmd/runtime-info.go. The client previously reached out to upstream release feeds on every invocation to print an upgrade hint; there is now no outbound release probing at all. - Containers and artifacts fully localized: the default image is built from the checked-out fork source, and hotfix binaries are copied from the local build context — no upstream prebuilt binaries are downloaded. The upstream publishing files
Dockerfile.release,Dockerfile.release.old_cpu, anddocker-buildx.shwere removed, and the obsolete MinIO hotfix upload target is disabled. - Packaging migrated to nFPM: replaced MinIO’s
pkgerwith standard nFPM. Artifact layout and install path are unchanged (/usr/local/bin/mcli, package namemcli,YYYYMMDDHHMMSS.0.0version scheme), but the vendor is now PGSTY, the license uses the SPDX identifierAGPL-3.0-or-later, and the DebianSectionmoved from empty toutils. - RPMs are now GPG-signed: RPMs are signed offline with the maintainer key (fingerprint
9592A7BC7A682E7333376E09E7935D8DB9BD8B20). All package metadata is asserted before signing, and the signature is re-verified with checksums regenerated afterwards. DEB and APK packages carry no package-level signature; their trust anchor lives at the repository layer. - Build provenance hardened: every previously published binary was stamped by the Go toolchain as built from a modified working tree (
vcs.modified=true), which broke the link between an artifact and its Git tag. This release fixes that and adds an enforcing check to both the release and test pipelines, so every binary is traceable to an exact commit.
Security Fixes
- SUBNET credentials redacted in debug logs: with
--debugenabled, SUBNET HTTP exchanges are printed in full. Previously theapi-key/api_keyquery parameters, authentication headers, and response bodies all reached the log in clear text — and SUBNET’s authentication and registration endpoints return API keys, licenses, and tokens in their responses. Both parameter spellings and duplicate values are now masked uniformly, sensitive response headers are redacted, and SUBNET response bodies are excluded from debug dumps entirely. The leak is inherited from upstream and present in every previous release, upstreammcincluded: if you have ever shared--debugoutput of SUBNET commands (mcli license .../mcli support ...), treat the API keys and licenses in it as exposed and rotate them. - Redaction isolated from caller state: debug tracing now dumps copies of the request and response, so redaction cannot mutate objects the caller still holds. Zero-length, fixed-length, and unknown-length response bodies are all covered, and callers can still read the response normally.
Dependency Updates
This cycle’s dependency work is security maintenance, not routine hygiene: every bump below except the term / mod / sync / tools refresh closes at least one published advisory in the Go vulnerability database, and govulncheck reports zero known vulnerabilities reachable from this release’s code. No security advisory has ever been published for minio/mc, minio-go, madmin-go, or minio/pkg themselves.
- Go build baseline upgraded from
1.26.2to1.26.5(the newest 1.26.x at release time), picking up the 1.26.3–1.26.5 security batches — including GO-2026-4970 (symlink-based root escape inos) and GO-2026-5856 (Encrypted Client Hello privacy leak incrypto/tls), the two most relevant to an S3 client that writes local files and speaks TLS. github.com/klauspost/compressfromv1.18.5tov1.18.7(closes GO-2026-5841).github.com/prometheus/prometheusfromv0.310.0tov0.311.3(closes GO-2026-5264, GO-2026-5381, GO-2026-5710).google.golang.org/grpcfromv1.79.3tov1.82.1(closes GO-2026-6061), with thegenprotofamily refreshed alongside.- The
golang.org/x/*family refreshed across the board:cryptov0.49.0→v0.53.0(the 14-advisory GO-2026-5005…5033 batch),netv0.52.0→v0.56.0(GO-2026-5025…5030 and GO-2026-5942),sysv0.42.0→v0.46.0(GO-2026-5024),textv0.35.0→v0.39.0(GO-2026-5970), plusterm,mod,sync, andtools. - Removed
aead.dev/minisignandgithub.com/minio/selfupdate, and synchronized the third-party credits file.
Engineering and Delivery
- Integration test dependencies pinned: CI no longer downloads the MinIO server from a mutable upstream URL. It now uses a versioned
pgsty/miniorelease archive verified by its SHA-256 digest, with Go pinned to1.26.5. - Release pipeline verification: a packaging validation workflow compares the binary inside all three package formats byte-for-byte against the build output, and checks package names, checksums, architecture fields, and every metadata field. The expected RPM metadata is sourced from the signing script itself, so configuration drift cannot strand a release part-way through signing.
- CI supply-chain hardening: every GitHub Action is pinned to a commit SHA with dependabot keeping them current, workflow permissions are narrowed to read-only, and a stale workflow pointing at the upstream organization’s project board was removed.
- Documentation: the English and Chinese READMEs now state this fork’s distribution channels and self-update policy explicitly, and installation instructions that would silently install upstream
mcwere removed.
Known issue
mcli watch (bucket event notification) receives no events against any published pgsty/minio server release. The cause is a silent streaming-flush regression on the server side, inherited from upstream — it is not a client problem, and the previous mcli release is affected identically. The fix was merged to the server’s master on 2026-07-29 but has not shipped in a published server release. See the SILO 20260618 release notes and PR #34.
Separately, this fork inherits upstream’s unfixed defects, and with the upstream repository archived they can only ever be fixed here. The most serious is minio/mc#5139: mirror --remove --watch can delete a live object from the target when a non-current version of it is removed from the source. Exercise caution combining --remove --watch on versioned buckets.
Related Commits
- 9603ee3: fix: redact SUBNET secrets in HTTP debug logs
- f6ae2b0: fix: disable self-update in Pigsty builds
- c05a6e4: build: update Go deps and toolchain to 1.26.5
- 1f105aa: build: use local fork artifacts for containers
- 1182da5: ci: pin fork integration test dependencies
- 9ee207f: docs: clarify Pigsty fork distribution channels
- 0686cd8: fix: isolate SUBNET debug redaction
- ad10a2a: build: complete local Docker context isolation
- 5f54221: docs: update mc README and cn version
- 02c0305: build: migrate release packaging to nFPM
- 4c4dcc4: build: harden release provenance and package metadata
2.10 - Silo 20260618 Released
Published: 2026-06-18 · Version: RELEASE.2026-06-18T00-00-00Z
This release is a security and dependency-maintenance update for the pgsty/minio fork. It hardens LDAP STS throttling, completes S3 Select oversized-record enforcement, removes the obsolete ReadMultiple internode storage-REST API, upgrades the Go build baseline to 1.26.4, and refreshes Go module dependencies to pick up additional third-party security fixes.
Note
Known issue: this release — like every earlier community release since RELEASE.2025-12-03T12-00-00Z — carries a silent streaming-flush regression inherited from upstream that breaks mc watch / bucket-notification listeners and S3 Select keep-alives. There is no workaround. The fix was merged to master on 2026-07-29 but has not shipped in a published server release; see PR #34 for the implementation.
Major Changes
- Remove the obsolete
ReadMultiplestorage-REST API: the legacy/rmplinternode endpoint is removed rather than patched in place, including its route, handler, client wrapper, storage interfaces, xlStorage methods, generated datatypes, and related metric. No production caller is expected after upstream multipart handling moved toReadParts, but clusters should still run a consistent release during rolling upgrades. - Complete S3 Select oversized-record enforcement: JSON Lines input now uses the bounded reader path, so oversized records are rejected consistently instead of bypassing limits on SIMD-capable CPUs. S3 Select stream errors now preserve the intended error code and wrap JSON parser failures as
JSONParsingError. - Harden LDAP STS rate-limit source bucketing: throttling is now keyed only by source IP, avoiding username-shared buckets that could be drained by one client to lock out a legitimate user. Trusted-proxy handling now resolves
X-Forwarded-Forfrom right to left, rejects catch-all trusted-proxy CIDRs, ignores RFC 7239Forwarded, and documents theX-Real-IPdeployment contract. - Refresh the Go runtime and module baseline: release, hotfix, goreleaser, and old-CPU Docker builds now use
golang:1.26.4-alpine;go.modis updated to Go1.26.4; and dependencies are refreshed across NATS, Prometheus, Azure SDK, Apache Thrift, gRPC, OpenTelemetry, Google API/auth, Gox/*, and related transitive libraries.
Direct Security Fixes
- CVE-2026-42600: remove the obsolete
ReadMultiplestorage-REST API to close the legacy internode file-read path exposed through/rmpl. - CVE-2026-39414: complete oversized S3 Select record enforcement for JSON Lines inputs and preserve correct S3 Select error semantics.
- CVE-2026-33419: further harden LDAP STS rate-limit accounting and trusted-proxy source-IP handling.
Dependency Security Updates
- Update
github.com/Azure/go-ntlmsspfromv0.1.0tov0.1.1, fixing CVE-2026-32952, where malformed NTLM challenges could panic a Go process. - Update
github.com/apache/thriftfromv0.22.0tov0.23.0, fixing CVE-2026-41602 in the GoTFramedTransportimplementation. - Update
github.com/nats-io/nats-server/v2fromv2.11.1tov2.11.15, absorbing the NATS 2.11.x security patch line. Notable fixes include pre-auth WebSocket and leafnode denial-of-service issues, MQTT authorization issues, JetStream management API authorization hardening, credential exposure fixes, and request identity-spoofing fixes, including CVE-2026-27889, CVE-2026-29785, CVE-2026-33217, CVE-2026-33218, CVE-2026-33222, and CVE-2026-33247. - Update
github.com/prometheus/prometheusfromv0.310.0tov0.311.3, absorbing Prometheus security fixes for remote-read denial of service, stored XSS in UI surfaces, and remote-write configuration secret exposure, including CVE-2026-42154, CVE-2026-44903, CVE-2026-42151, and CVE-2026-40179. - Upgrade the release build baseline through Go
1.26.4and refresh supporting Go module families, includinggolang.org/x/crypto,golang.org/x/net,golang.org/x/sys,golang.org/x/text,google.golang.org/grpc, and OpenTelemetry. These updates keep the fork aligned with patched upstream dependency baselines even where the previously pinned version was already past the specific public advisory range.
Related Commits
2.11 - Silo 20260417 Released
Published: 2026-04-17 · Version: RELEASE.2026-04-17T00-00-00Z
This release focuses on security hardening and compatibility tightening. It bundles fixes across OIDC, LDAP STS, S3 Select, replication metadata handling, unsigned-trailer flows, the Snowball upload path, and multiple dependency- and Go toolchain-related security issues, while also incorporating the LDAP TLS regression fix and a cleanup of community-fork documentation.
Major Changes
- Tighten the identity-authentication flow: OIDC / WebIdentity now accepts only asymmetrically signed
ID Tokenvalues backed by the IdPJWKS; symmetrically signed tokens such asHS256are no longer accepted. LDAP STS also now hides the distinction between unknown-user and bad-password failures to reduce username-enumeration risk. - Update LDAP STS rate limiting: limits now apply to both source IP and normalized username, and successful requests no longer consume quota incorrectly. By default MinIO now uses only the socket peer address as the source and no longer trusts
X-Forwarded-For,X-Real-IP, orForwarded; to rate-limit by real client IP, configureMINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIESexplicitly. - Make upload and write paths stricter: presigned query parameters can no longer be combined with
unsigned-trailerPUTor multipart uploads. Snowball auto-extract now also performs full signature validation on theunsigned-trailerpath and rejects anonymous or forged-signature requests. - Prevent replication metadata spoofing: internal
X-Minio-Replication-*headers attached to ordinaryPUT/COPYrequests are now rejected or ignored, and only trusted replication flows may write the related internal metadata. - Clarify S3 Select error semantics: oversized CSV and line-delimited JSON records now return
OverMaxRecordSizedirectly instead of the genericInternalError; clients or alerting rules that depend on the old error code should be adjusted. - Upgrade the runtime and dependency baseline: fix the regression where
ldaps://did not correctly apply TLS settings, replaceminio/pkg/v3withpgsty/minio-pkg/v3, and pin several critical dependencies that are prone to breaking changes. The release also upgradesgo-jose,go.opentelemetry.io, and Go1.26.2to unify the build and release baseline. - Refresh documentation and security guidance: update
SECURITY.md,VULNERABILITY_REPORT.md,docs/sts/ldap.md, and related documents, add a security advisory index, and switch upstreamminio/minioreferences in the security guidance over topgsty/minio.
Fixed CVEs
- CVE-2026-34986: upgrade
go-josetov4.1.4and fix known security issues in the JWT / JOSE dependency chain. - CVE-2026-39883: upgrade the
go.opentelemetry.iodependency stack to fix the PATH-hijacking risk. - CVE-2026-33322: restore the strict JWKS-only OIDC JWT verification path to block keyring injection and algorithm-confusion risk.
- CVE-2026-33419: systematically harden LDAP STS authentication, rate limiting, source-address identification, and accounting logic across four follow-up fixes.
- CVE-2026-34204: reject injection of
X-Minio-Replication-*metadata by untrusted requests to prevent objects from being written with invalid replication state. - CVE-2026-39414: reject oversized S3 Select records early to avoid continued buffering and parsing of abnormal inputs.
- GHSA-hv4r-mvr4-25vw: close the unsigned-trailer query-auth bypass.
- GHSA-9c4q-hq6p-c237: harden unsigned-trailer authentication and signature validation in Snowball auto-extract scenarios.
- CVE-2026-32280, CVE-2026-32281, and CVE-2026-32283: upgrade Go to
1.26.2and absorb the upstream toolchain and stdlib security fixes.
Related Commits
- c878ca0: fix: pin deps with breaking changes and fix LDAP TLS regression (#15)
- e970ec5: fix: upgrade go-jose to v4.1.4 to patch CVE-2026-34986
- a206510: fix: CVE-2026-39883 upgrade go.opentelemetry.io
- fd65f11: merge: PR #18 upgrade go-jose to v4.1.4 for CVE-2026-34986
- bc087e4: merge: PR #19 upgrade go.opentelemetry.io for CVE-2026-39883
- f1f2239: fix: CVE-2026-33322 restore JWKS-only OIDC JWT verification
- 6619d0c: fix: CVE-2026-33419 harden LDAP STS auth
- fcb8f24: fix: CVE-2026-34204 reject untrusted replication metadata
- c5765dc: fix: CVE-2026-39414 reject oversized S3 Select records
- f444b6f37: fix: fake CVE-2026-40027 block unsigned-trailer query auth bypass
- efb6e5b00: fix: fake CVE-2026-40028 harden snowball unsigned-trailer auth
- 9a4b3cd: fix: CVE-2026-32280/CVE-2026-32281/CVE-2026-32283 upgrade Go to 1.26.2
- c55b52c: fix: CVE-2026-33419 preserve LDAP STS rate limits on success
- 817a457: fix: CVE-2026-33419 harden LDAP STS rate-limit source IP
- 084a154: fix: CVE-2026-33419 tighten LDAP STS rate-limit accounting
- 16e34f9: docs: refresh security guidance and fork references
2.12 - Silo 20260325 Released
Published: 2026-03-25 · Version: RELEASE.2026-03-25T00-00-00Z
This is a maintenance release centered on packaging, stability, and security disclosure. It improves the shipping artifacts, fixes an LDAP TLS regression, and explicitly documents the secure dependency set carried by the release.
Major Changes
- Bundle
mcli/mcinto the Docker image and add checksum verification for a better out-of-the-box image experience. - Fix the LDAP TLS regression affecting
ldaps://deployments so TLS settings are correctly honored. - Remove inherited upstream CI/CD workflows that are no longer used in the community-maintained fork.
- Pin several critical dependencies to avoid further fallout from upstream breaking changes.
Fixed CVEs
- CVE-2026-24051: the release notes explicitly call out
go.opentelemetry.io/otel/sdk v1.42.0, which avoids the macOS PATH-hijacking arbitrary code execution issue. - CVE-2025-10543: the release notes explicitly ship
github.com/eclipse/paho.mqtt.golang v1.5.1, fixing incorrect MQTT packet encoding for oversized UTF-8 strings. - CVE-2025-58181: the release notes explicitly ship
golang.org/x/crypto v0.49.0, fixing unbounded memory consumption insshGSSAPI authentication handling.
Related Commits
2.13 - Silo 20260321 Released
Published: 2026-03-21 · Version: RELEASE.2026-03-21T00-00-00Z
This maintenance release is built around the Go 1.26.1 upgrade and a broad dependency refresh. Beyond stricter compiler and linter compatibility fixes, it also delivers the most substantial security dependency refresh in the current release line.
Major Changes
- Upgrade the build environment from Go
1.26.0to Go1.26.1. - Refresh direct and indirect dependencies to converge on the newer toolchain.
- Fix linter and test issues exposed by the stricter Go 1.26.1 checks.
Fixed CVEs
- CVE-2026-27137: Go stdlib
1.26.0->1.26.1fixes incomplete email-constraint enforcement incrypto/x509. - CVE-2026-27138: Go stdlib
1.26.0->1.26.1fixes acrypto/x509panic triggered by malformed certificates. - CVE-2026-25679: Go stdlib
1.26.0->1.26.1fixes insufficient validation of IPv6 host literals innet/url. - CVE-2026-27139: Go stdlib
1.26.0->1.26.1fixesFileInfometadata escaping theRootboundary inos. - CVE-2026-27142: Go stdlib
1.26.0->1.26.1fixes missing URL escaping inhtml/templateformeta refreshcontent. - CVE-2026-26958:
filippo.io/edwards25519v1.1.0->v1.2.0fixes incorrect or undefinedMultiScalarMultbehavior. - CVE-2025-10543:
github.com/eclipse/paho.mqtt.golangv1.5.0->v1.5.1fixes incorrect MQTT packet encoding for oversized UTF-8 strings. - CVE-2026-24051:
go.opentelemetry.io/otel/sdkv1.38.0->v1.42.0fixes the macOS PATH-hijacking arbitrary code execution issue. - CVE-2026-33186:
google.golang.org/grpcv1.77.0->v1.79.3fixes authorization bypass caused by a missing leading slash in the HTTP/2:pathpseudo-header.
Related Commits
2.14 - Silo 20260314 Released
Published: 2026-03-14 · Version: RELEASE.2026-03-14T12-00-00Z
This release switches the project to the community-maintained Console fork and performs a sizeable dependency refresh to establish the base for the later Go 1.26.x maintenance releases.
Major Changes
- Switch to the community-maintained
georgmangold/console v1.9.1fork in place of the unmaintainable upstream Console dependency. - Refresh a large portion of the direct and indirect dependency graph so the new Console and toolchain combination builds cleanly.
- Fix the
go vetformat directive issue ingrid_test.goand adjust tests for the HTTP behavior changes in Go 1.26.
Fixed CVEs
- CVE-2025-47913:
golang.org/x/cryptov0.37.0->v0.46.0fixes a panic inssh/agentwhen handling malformed responses. - CVE-2025-58181:
golang.org/x/cryptov0.37.0->v0.46.0fixes unbounded memory consumption insshGSSAPI authentication parsing. - CVE-2025-47914:
golang.org/x/cryptov0.37.0->v0.46.0fixes a panic inssh/agentcaused by malformed identity messages. - CVE-2025-47911:
golang.org/x/netv0.39.0->v0.48.0fixes quadratic parsing complexity inhtml.Parsefor crafted inputs. - CVE-2025-58190:
golang.org/x/netv0.39.0->v0.48.0fixes an infinite parsing loop ingolang.org/x/net/html.
Related Commits
2.15 - Silo 20260214 Released
Published: 2026-02-14 · Version: RELEASE.2026-02-14T12-00-00Z
This early infrastructure-focused community release restores the embedded Console, introduces GitHub CI/CD, and lifts the Go baseline to 1.26.0, which also absorbs a batch of security fixes from the older toolchain generation.
Major Changes
- Restore the embedded Console and refresh the README to clarify the community fork position.
- Add GitHub CI/CD workflows as the base for automated builds and multi-platform delivery.
- Add quick links for docs, Docker, the GitHub repository, and installation through the
pigpackage manager.
Fixed CVEs
These issues were absorbed as part of the Go 1.25.5 -> 1.26.0 upgrade:
- CVE-2025-68121:
crypto/tlscould incorrectly accept mutated CA configuration during session resumption. - CVE-2025-61730: TLS 1.3 could process handshake messages incorrectly across encryption-level boundaries.
- CVE-2025-61726:
net/urlquery parsing could be abused for memory exhaustion. - CVE-2025-61728:
archive/zipcould consume excessive CPU while building archive indexes. - CVE-2025-68119:
cmd/gocould trigger unexpected code execution when invoking external VCS tooling. - CVE-2025-61731: the
#cgo pkg-config:directive could be abused for arbitrary file writes. - CVE-2025-61732:
cmd/cgocomment parsing discrepancies could enable code smuggling.
Related Commits
2.16 - Silo 20251203 Released
Published: 2025-12-15 · Version: RELEASE.2025-12-03T12-00-00Z
This is the earliest traceable community release. Its purpose is to establish the community packaging and distribution baseline rather than to deliver incremental fixes over an earlier community release.
Major Changes
- Build the community packaging flow around
minio/pkger. - Choose a maintenance-mode upstream MinIO baseline as the starting point for the community-maintained fork.
- Produce the first
apk,deb, andrpmartifacts for ongoing community releases.
Fixed CVEs
- This is the first community release. The GitHub Release does not provide a delta-style security-fix list against an earlier community version, and this page does not attempt to reconstruct the full historical CVE delta against the upstream maintenance baseline.
Related Commits
- d4cd4b4: RELEASE.2025-12-03T12-00-00Z with go 1.25.5
3 - SILO Security Chronicle
This is the security chronicle of the SILO community fork, listed from newest to oldest. Each CVE has its own article: the original threat model, the turns taken during review, the rejected alternatives, the final invariant, the evidence, and the compatibility cost all stay with that incident.
3.1 - CVE-2025-62506: Session-Policy Privilege Escalation
Status: Inherited and released
First Silo community release: RELEASE.2025-12-03T12-00-00Z
Upstream fixed release: RELEASE.2025-10-15T17-29-55Z
GitHub advisory: GHSA-jjjj-jwhf-8rgr
Upstream fix: minio/minio#21642
A service account or STS account with a restricted session policy could use an “own account” operation to create another service account without the restriction. The child account then inherited broader parent permissions, turning valid low-privilege credentials into a privilege-escalation path.
Silo did not need a separate backport. The upstream fix and its two regression groups were already in the history from which the community fork was released. This note records that inheritance without duplicating the tests or reconstructing the investigation already captured by the upstream advisory and PR.
Commit mapping
| Role | Repository | Commit | Evidence |
|---|---|---|---|
| Upstream remediation | minio/minio |
c1a49490 |
Merge commit for PR #21642 |
| Silo inheritance | pgsty/silo |
c1a49490 |
The fork preserves the same commit object and SHA; the first Silo community release descends from it |
The identical SHA is the mapping: this is inherited source history, not an independent Silo implementation with a merely similar patch. The repository’s canonical advisory ledger keeps the same source-to-fork record.
Regression evidence
PR #21642 added two regression groups and runs each against root and non-root parents:
TestServiceAccountPrivilegeEscalationBug2_2025_10_15covers restricted service accounts.TestSTSPrivilegeEscalationBug2_2025_10_15covers restricted STS accounts.
Both remain wired into the standard cmd test suites, so go test ./cmd executes them. No parallel or replacement regression test was added for this publication update.
Fix and operator boundary
When a session policy exists, the fix clears the DenyOnly shortcut before evaluating it. An “own account” exception therefore cannot turn “not explicitly denied” into permission: the restricted policy must actually allow the action.
Every Silo community release contains this commit. Operators migrating from a MinIO build older than the upstream fixed release should upgrade, review service accounts created by restricted service or STS identities, and revoke suspicious child accounts. This inheritance statement does not extend support to historical Silo releases; the current release line remains the supported line.
3.2 - CVE-2026-32285: The jsonparser Advisory That Required No Patch
Status: Closed without a code change
GitHub issue: pgsty/minio#26
Security maintenance is not always a sequence of “find a vulnerability, then ship a patch.” The initial assessment of CVE-2026-32285 was that the repository might still carry a vulnerable jsonparser; replacing the dependency or maintaining another fork was even considered. Checking the resolved module version and actual reachability changed the conclusion: the tree already used v1.1.2, which contained the fix, and govulncheck found no reachable vulnerable symbol.
The right final action was not to manufacture an upgrade. It was to record the evidence and close the issue.
What was wrong with the initial assumption
The issue was first understood as “this dependency has no fixed version.” Acting on that premise without verification could have produced several changes that looked proactive but made the project worse:
- changing the dependency graph for no security benefit;
- introducing compatibility regressions in the name of a nonexistent fix;
- adding another fork that would need long-term maintenance;
- implying that previous SILO releases were demonstrably exposed when that had not been established.
Security work cannot be measured by whether it produces a diff. Leaving correct code unchanged is itself a security decision, and it needs evidence.
Investigation
The investigation narrowed the question through four layers of evidence:
- Confirm the version actually selected in the current
go.modandgo.sumgraph. - Check the upstream release and establish that
v1.1.2already contained the relevant fix. - Run and inspect
govulncheck; it reported no reachable vulnerable symbol. - Attribute the discrepancy in the issue to stale advisory or vulnerability-database information, not to a vulnerability still present in the source tree.
Four claims must remain separate: a version was once listed as affected, a package is imported, a vulnerable symbol is reachable in the program, and remote input can actually exploit that path. None of them proves the others.
Why there was no “just in case” upgrade
If the selected version already includes the fix, bumping to an arbitrary newer version does not make the system safer. It only expands the change surface and makes later regressions harder to attribute. That is especially risky in a large Go module graph.
The final decision was therefore to:
- avoid committing a fictitious fix;
- preserve the version and reachability evidence in the issue;
- keep version gates and
govulncheckin place to detect a future dependency rollback; - treat “no change required now” as a dated conclusion, not a permanent exemption.
Verification boundary
This incident established that the checkout examined on 2026-04-15 did not require a code change for CVE-2026-32285. It does not establish that every future branch, module graph, or release will remain unaffected. A dependency downgrade or a change in module selection requires the version and reachability checks to be repeated.
This article records the investigation and the basis for closure. The original govulncheck was not rerun while preparing this chronicle.
The principle this incident left behind
The objective of security maintenance is an accurate risk conclusion, not a patch for every issue. For a dependency CVE, ask in order: which version is actually selected, whether the vulnerable code enters the program, whether the symbol is reachable, and whether a deployed entry point makes it exploitable. Only when those answers require a source change should the investigation produce a diff.
3.3 - CVE-2026-33322: OIDC JWT Algorithm Confusion
Status: Released
First containing release: RELEASE.2026-04-17T00-00-00Z
Affected entry points: AssumeRoleWithWebIdentity, AssumeRoleWithClientGrants
GitHub issue: pgsty/minio#22
The old implementation placed the OIDC client secret in the JWT verifier keyring while also accepting HMAC signing methods. An attacker who knew that client secret could therefore mint an HS-signed token and exchange it through STS for temporary credentials. The final fix restored asymmetric, JWKS-only verification. It deliberately broke HS256/384/512 compatibility instead of keeping an option that would reintroduce ambiguous trust semantics.
The vulnerability was not the absence of signature verification
At first glance, the old code did verify JWT signatures. The boundary that failed was more specific: which kind of key the verifier would accept, and whether the token header could select an algorithm with semantics that did not match that key’s intended role.
The attack chain required several conditions:
- the attacker obtained the OIDC client secret;
- the attacker constructed an HMAC-signed ID token;
- the verifier treated the client secret as an HMAC signing key;
- the token reached the WebIdentity or ClientGrants STS flow and was exchanged for temporary credentials.
Disclosure of a client secret is already serious, but it should not automatically confer the power to issue arbitrary user ID tokens. Combining those capabilities in one keyring created the algorithm-confusion vulnerability.
A compatibility path was implemented, then deliberately removed
During the fix, an allow_hmac-style compatibility path was implemented. It appeared reasonable: keep the secure default while letting users with a real requirement opt in. But retaining a shared secret in the general verifier keyring meant administrators would need to understand that the option expanded the entire STS trust boundary. Any future drift in the method allowlist could reopen the flaw.
The trade-off became clear:
| Option | Benefit | Risk | Decision |
|---|---|---|---|
| Keep the secret keyring and restrict some algorithms | Small change; preserves HMAC IdPs | The keyring still mixes two trust semantics | Rejected |
Add an allow_hmac option |
Makes compatibility explicit | The option is difficult to reason about correctly and expands the test surface | Implemented, then reverted |
| JWKS-only verification | Clear boundary; refresh and retry use the same parser | HS users must migrate | Accepted |
The most important decision was not what code was added, but that a completed compatibility implementation was removed.
Final invariants
The fix was concentrated in the OIDC JWT verification path and established four rules:
- verifier keys come only from the identity provider’s JWKS;
- the OIDC client secret never enters the JWT verification keyring;
- HS256, HS384, and HS512 are always rejected;
- the ordinary RS256 flow and JWKS refresh/retry use the same method allowlist.
The CVE was not used as a pretext for expanding JOSE support. PS256 and EdDSA remained out of scope.
Verification and release
The development record includes HS256 rejection, RS256 acceptance, JWKS refresh/retry regression tests, and focused go test ./internal/config/identity/openid. Temporary compatibility helpers, configuration, and tests were all removed from the final diff.
The public fix is f1f2239, released with SILO 2026-04-17. This article records the historical verification; those tests were not rerun while preparing the chronicle.
Compatibility cost
This is an explicit breaking change. Identity providers that still issue HS256/384/512 tokens must migrate to JWKS-backed RSA or ECDSA before upgrading SILO. The project chose a narrower trust model that is easier to explain and audit over preserving a configuration that happened to work before.
3.4 - CVE-2026-33419: LDAP STS Enumeration and the Throttling Chain
Status: Released, followed by two rounds of corrections
First containing release: RELEASE.2026-04-17T00-00-00Z
Complete correction: RELEASE.2026-06-18T00-00-00Z
GitHub issue: pgsty/minio#23
The core vulnerability was straightforward: LDAP STS returned different results for “user does not exist” and “password is wrong,” creating a username oracle. The first fix unified the external authentication failure and added limits by source IP and username. Continued review then showed that success refunds, spoofable source headers, reservation accounting, and the shared username bucket could turn the security control itself into a new attack surface.
The final June design removed the username bucket that enabled precise account lockout, retained only the source-IP bucket, and made proxy attribution an explicit deployment contract.
Initial threat model
The entry point is AssumeRoleWithLDAPIdentity. An attacker needs no existing MinIO account. Access to the LDAP STS endpoint is enough to compare the code, status, or message returned for an unknown user and a wrong password, enumerate valid usernames, and combine that knowledge with password spraying, guesses about organizational naming, or social engineering.
The fix could not simply disguise every error as “wrong password.” LDAP connection, lookup-bind, and directory-service failures still needed to appear as infrastructure errors, or operators would lose the ability to diagnose the service.
First round: uniform responses and a limiter
The initial fix on 2026-04-15 did three things:
- unknown user and bad password returned the same external STS authentication error;
- LDAP infrastructure failures still returned 500, with the real cause retained in the server log;
- a new in-memory limiter initially created buckets for both source IP and normalized username.
This closed the content side channel and raised the cost of brute-force attempts, but the limiter state machine and source attribution exposed more problems.
Second round: success, attribution, and accounting
The follow-up changes on April 16 addressed three classes of defects:
- Successful authentication must not consume the failure allowance; the reserve/commit/cancel/refund lifecycle had to be explicit.
- The socket peer must be used by default;
X-Forwarded-For,X-Real-IP, andForwardedcannot be trusted merely because a request supplies them. - Refund and capacity need hard bounds so cancel logic cannot mint tokens.
A proxy must be placed on an explicit trusted allowlist before it can influence source attribution.
Third round: remove the username bucket
Adversarial review in June overturned the intuition that “source plus username must be stronger than source alone.” A shared username bucket could be exhausted continuously from arbitrary origins. With only a low request rate, an attacker could lock a targeted account before the legitimate user ever reached an LDAP bind.
The final fix therefore:
- removed the per-username bucket;
- peeled trusted hops from XFF right to left and selected the first untrusted address;
- rejected trusted-proxy footguns such as
0.0.0.0/0and::/0; - stopped using
Forwardedfor security-sensitive bucketing; - allowed
X-Real-IPonly under a contract in which the proxy overwrites rather than forwards client input.
This turn in the review shows that a security control needs its own threat model. More dimensions of throttling do not automatically mean more security.
Rejected alternatives
| Option | Why it was rejected |
|---|---|
| Perform a dummy bind for unknown users | Amplifies LDAP load and creates a second, error-prone authentication path after the content side channel is already closed |
Bucket all IPv6 clients by /64 |
Legitimate users behind the same site or carrier prefix can throttle one another |
| Take the leftmost XFF value | Client-controlled and therefore spoofable |
Fall back to the peer when XFF and X-Real-IP disagree |
An attacker can create disagreement deliberately and collapse every user behind a proxy into one bucket |
Fully support RFC 7239 Forwarded |
Security-sensitive parsing complexity outweighs the practical benefit |
Verification and release
The historical record covers limiter reserve/commit/cancel/refund behavior, concurrency, success and infrastructure failures, external equivalence of unknown-user and bad-password responses, and RemoteAddr, spoofed-header, trusted-proxy, multi-hop, and catch-all-CIDR cases. Focused package tests and builds were recorded as passing.
The LDAP security end-to-end test skips when _MINIO_LDAP_TEST_SERVER is absent, so an outer ok cannot be presented as proof of the full LDAP scenario.
The first public fix was 6619d0c. Follow-up corrections include c55b52c, 817a457, 084a154, and 5e40665.
Final cost and residual risk
- The limiter now keys only on source IP and gives up a hard per-account throttle across different origins.
- It is per-node and in-memory, not a cluster-wide password defense.
- Botnets, distributed origins, IPv6 address rotation, and LDAP bind timing remain.
- Incorrect trusted-proxy configuration can still destroy source attribution.
- A
Forwarded-only deployment falls back to the peer bucket and loses granularity.
Rate limiting can reduce attempts from one source. The uniform external authentication response is what actually conceals whether a username exists.
3.5 - CVE-2026-34204: Replication Metadata Injection
Status: Released
First containing release: RELEASE.2026-04-17T00-00-00Z
GitHub issue: pgsty/minio#24
Ordinary PUT and COPY requests could smuggle X-Minio-Replication-* headers into internal X-Minio-Internal-* SSE metadata, creating objects whose replication state did not match the authorized path and could even make them unreadable. The final fix stopped accepting replication-only metadata by default, restored it only in a trusted flow authorized for ReplicateObjectAction, and sanitized CopyObject before any header consumer ran.
Threat model
An attacker needed only ordinary object-write permission, not internode credentials. The input came entirely from client-controlled X-Minio-Replication-* headers, but metadata extraction converted it into internal replication or SSE state.
Later read paths interpreted the object according to that false internal state. The result could be an unreadable object: an integrity and availability failure. Almost every production server accepting untrusted writes needed to be treated as affected.
The root problem was not the header name. It was that data from an untrusted source acquired internal semantics without passing replication authorization.
Reject the whole request, or sanitize precisely?
Rejecting an ordinary request whenever it contains a replication header is the most obvious fix. It would also turn a header clients were previously allowed to send and have ignored into a hard failure. The final design was more precise:
- the default extraction path does not accept replication-only metadata;
- ordinary
PUTandCOPYstrip those fields first; - only a path authorized for
ReplicateObjectActionrestores them; - replica-status writes use the same trusted condition;
- legitimate multipart and Snowball replication flows explicitly restore the SSE metadata they require.
That keeps the compatibility change inside internal semantics instead of expanding it to every client carrying an extraneous header.
Why CopyObject had to sanitize early
CopyObject headers are not used only for the final metadata map. They can be consumed earlier by precondition logic and SSE-C source handling. Removing them immediately before the object write is too late: earlier consumers have already been contaminated.
The final sanitization occurs before those consumers. “Untrusted replication headers never enter internal semantics” becomes one invariant instead of a convention every downstream function must remember to enforce.
Implementation and verification
The change covered handler utilities, object handlers, and multipart handlers, with tests at several layers:
- trusted and untrusted metadata extraction at the helper layer;
- malicious
PUTandCOPYcases at the handler layer; CopyObjectheader sanitization;- red/green comparison between the vulnerable parent and the patched tree;
- live-server before/after behavior showing that a malicious header no longer made an object unreadable;
- continued operation of legitimate replication, multipart, and Snowball flows.
The public fix is fcb8f24. This article preserves the historical verification boundary; no live server was started again while preparing the chronicle.
Cost and residual risk
- Internal replication headers supplied by ordinary clients are now ignored or stripped.
- Replication-only metadata must be restored explicitly inside an authorized branch.
- If a future replication entry point forgets to restore it, the result should be a functional regression rather than another untrusted write path.
- The audit focused on replication headers; it does not establish that every
X-Minio-Internal-*field has undergone the same trust review.
This incident leaves a simple review question: a field that looks “internal” is not necessarily trusted. Ask where it came from and which authorization decision allowed it to acquire internal meaning.
3.6 - CVE-2026-39414: Oversized S3 Select Records and a SIMD Bypass
Status: Released; the second-round fix was completed in June
Initial fix release: RELEASE.2026-04-17T00-00-00Z
Complete fix release: RELEASE.2026-06-18T00-00-00Z
GitHub issue: pgsty/minio#25
The first fix in April reused the existing 1 MiB maxCharsPerRecord limit for both CSV and ordinary JSON Lines. This prevented unbounded buffering while waiting for a delimiter and returned the explicit OverMaxRecordSize error to clients. A June review then found that CPUs with SIMD support took a different simdjson fast path that bypassed the limit completely.
The final solution sent JSON Lines through the bounded reader on every CPU. The same review also corrected error mapping, parser errors, and the flush of completed records before a terminal error. SILO temporarily gave up the SIMD fast path in exchange for consistent security semantics.
Threat model
An attacker can submit or query an object containing an extremely long single record. The reader continues buffering until it sees a record delimiter, allowing memory and CPU denial of service. More subtly, the same input can select a different implementation according to the machine’s CPU features. Safe behavior on a test machine does not necessarily prove safe behavior in production.
Error semantics are part of the fix. If an oversized record appears only as a generic InternalError, clients and monitoring systems cannot distinguish an enforced security limit from a server failure.
First round: reuse the existing 1 MiB invariant
The first patch did not invent a new configuration knob. It applied the existing maxCharsPerRecord = 1 MiB rule:
- the CSV splitter and line-delimited JSON rejected oversized records before buffering or parsing them further;
- the earliest splitter error was preserved instead of being overwritten by a partial decode;
- the error propagated as
OverMaxRecordSizerather than collapsing intoInternalError.
This was a deliberate compatibility contraction. Clients with lines or records larger than 1 MiB now had to split their input.
Second round: a hardware-dependent bypass
Following the call chain again in June exposed this path:
When simdjson.SupportedCPU() returned true, JSON Lines bypassed the bounded json.PReader. The third-party parser kept reading past a chunk boundary until it found a newline. A generic reader wrapper could not simultaneously preserve already completed records and guarantee a bound on the next record.
The final choice was not another wrapper. JSON Lines temporarily stopped using the SIMD path and always used the bounded PReader. If SIMD support returns, that implementation must enforce the same record limit itself and pass the same CPU-independent regression suite.
Stream semantics corrected in the same round
The review also fixed several adjacent behaviors:
- use
errors.Asto pass through errors implementingSelectError, not just one concrete type; - have the JSON worker wrap parser failures as
JSONParsingError; - flush completed records still waiting below the batch threshold before emitting a terminal error event;
- preserve error priority in input order instead of letting a later oversized record overwrite an earlier parse error.
Those details determine whether a client sees the correct failure or a resource-limit fix that quietly broke the streaming protocol.
Deliberately left outside this CVE
- The historical mismatch between CSV
AllowQuotedRecordDelimiterand the outer physical-newline splitter. - Whether
\rin CRLF counts toward the record length. - Restoring SIMD performance without an equivalent bound.
These questions may be real, but they require independent AWS-compatibility evidence or a more complex quote-aware splitter. They did not belong in a security patch based on guesses.
Verification and release
The historical record includes oversized JSON Lines, error-code preservation, and behavior tests that do not depend on the local machine’s SIMD capabilities. go test ./internal/s3select/... -count=1 and git diff --check were recorded as passing.
The initial public fix was c5765dc; the complete June correction is fd69c89. Those tests were not rerun while preparing this article.
Final trade-offs
- JSON Lines performance may decrease; this incident did not produce a benchmark that quantifies it.
- The 1 MiB per-record limit rejects oversized input accepted by previous releases.
- Quoted, multiline CSV semantics still need separate work.
- Any future CPU-specific fast path must share the slow path’s security tests.
The second fix leaves the central lesson: a security invariant must hold across hardware-dependent paths. A green test on one CPU does not prove that another execution engine is protected.
3.7 - CVE-2026-40344: Snowball Auto-Extract Authentication Bypass
Status: Released
First containing release: RELEASE.2026-04-17T00-00-00Z
GitHub advisory: GHSA-9c4q-hq6p-c237
Snowball’s PutObjectExtractHandler omitted the streaming unsigned-trailer authentication case. A tar stream with a forged signature could enter untar() before authentication completed, and one request could fan out into many object writes. The final fix initialized the correct reader, handled the decoded length, and completed SigV4 verification before any tar byte reached the extractor.
Why the identifier changed
The official CVE had not been assigned when the fix was written, so the commit subject used the temporary identifier fake CVE-2026-40028. The final identifier is CVE-2026-40344. The historical commit was not rewritten; the advisory and this article use the official number.
From one missing authentication case to bulk object writes
The entry point was Snowball / PutObjectExtract auto-extraction. The request used unsigned-trailer streaming, an authentication type the old handler did not cover as ordinary PUT did.
The danger was larger than one incorrectly authorized request. Once the tar stream entered untar(), that request could create multiple attacker-chosen objects. An authentication omission was therefore amplified into a bulk-write problem.
Final invariant: the extractor sees zero bytes on failure
The key statement in the fix was:
If authentication ultimately fails,
untar()must have seen zero bytes.
That rule excludes “extract first, then roll back if authentication fails.” Object writes travel through several paths, and proving a complete rollback is much harder than proving that input never crossed the boundary. Authentication had to close before data entered the extractor.
Implementation
The final change:
- recognized
authTypeStreamingUnsignedTrailer; - read
X-Amz-Decoded-Content-Length; - used
newUnsignedV4ChunkedReader(); - performed complete SigV4 request verification before entering
untar(); - preserved valid signed Snowball requests and CRC32 trailer flows.
Verification
The historical commit and investigation record cover:
- rejection of a forged-signature Snowball unsigned-trailer request;
- rejection of anonymous Snowball writes to a non-public bucket;
- successful extraction with a valid signature and trailing CRC32;
- red/green comparison between the vulnerable parent and the patched tree;
- containerized before/after smoke tests.
The public fix is efb6e5b00. The container tests were not rerun while preparing this article.
Compatibility and residual risk
- Clients that relied on an unsigned-trailer Snowball combination whose signature was never really verified will fail after upgrading.
- Authentication now closes before extraction, but tar paths, archive-size limits, and object-count limits remain separate security surfaces.
- Snowball and ordinary unsigned-trailer requests now share a reader; future changes must regress both paths together.
The essence of the fix was not another if. It moved the authentication decision in front of the actual amplification boundary.
3.8 - CVE-2026-41145: Unsigned-Trailer Query Authentication Bypass
Status: Released
First containing release: RELEASE.2026-04-17T00-00-00Z
GitHub advisory: GHSA-hv4r-mvr4-25vw
Query-string SigV4 credentials could enter a STREAMING-UNSIGNED-PAYLOAD-TRAILER data flow, while the old code verified the signature only when an Authorization header was present. A request carrying a valid access-key identifier could therefore complete a write without a correct signature.
The final fix moved presigned rejection and SigV4 verification into newUnsignedV4ChunkedReader(), making every caller consuming that stream share one authentication boundary.
Identifier note
The official CVE had not been assigned when the patch was written, so its commit subject used fake CVE-2026-40027. The final identifier is CVE-2026-41145. The historical commit remains unchanged; public material uses the official identifier.
Root cause: authentication was coupled to transport form
The affected entry points included PutObject and PutObjectPart. The request selected STREAMING-UNSIGNED-PAYLOAD-TRAILER, with its credentials and signature in the query string rather than the Authorization header.
The old handler used header presence to decide whether to verify a signature. The body reader still consumed the data normally, silently degrading query authentication into something close to an anonymous write. The attacker needed to know a valid access-key identifier, but did not need to produce a correct signature.
The problem was not failure to parse the query parameters. It was that authentication depended on how credentials were transported instead of the trust boundary at which the stream was consumed.
Why the patch did not live in each handler
| Option | Risk | Decision |
|---|---|---|
Add header/query checks separately to PutObject and PutObjectPart |
Closes today’s entry points, but a new caller can omit the check again | Rejected |
| Invent a compatible presigned unsigned-trailer protocol | Greatly expands protocol and test surface without an existing support contract | Rejected |
Reject and verify centrally in newUnsignedV4ChunkedReader() |
Forces every consumer through the same boundary | Accepted |
Anonymous unsigned-trailer requests were not prohibited wholesale. If bucket policy explicitly permits anonymous writes, they can still follow the anonymous authorization path. The forbidden state is the mixture of query credentials with no verification of those credentials.
Implementation and verification
The fix performs presigned rejection and SigV4 verification at the reader entry in cmd/streaming-v4-unsigned.go, while removing the gates in the PutObject and multipart handlers that depended on header presence.
New tests cover forged query PUT, multipart, mixed authentication, and anonymous policy. The historical record also includes a vulnerable-parent write that succeeded, failure on the patched tree, and live-server before/after smoke tests showing that header-authenticated and valid anonymous flows continued to work.
The public fix is f444b6f37. The live exploit was not rerun while preparing this article.
Compatibility and residual risk
- Presigned/query unsigned-trailer is now explicitly unsupported, an intentional breaking change.
- Moving the fix into the reader significantly reduces the chance that a sibling handler omits the check again.
- Other streaming authentication modes still need their own audits; this reader fix does not establish that every SigV4 streaming combination is safe.
The shape of this fix matters as much as its payload: when several handlers share an authenticated data stream, authentication belongs to the reader rather than to optional checks in each caller.
3.9 - CVE-2026-42600: ReadMultiple Storage-REST Path Traversal
Status: Released
First containing release: RELEASE.2026-06-18T00-00-00Z
GitHub advisory: GHSA-xh8f-g2qw-gcm7
Affected scope: Distributed erasure only; cluster-root / internode JWT required
The msgpack body of /rmpl carried Bucket, Prefix, and Files. The old code joined those values into filesystem paths without a containment check. The initial fix implemented full preflight validation. Continued call-chain review then found that this API had had no production caller since 2024. The final solution changed from “retain and harden” to removing the route, handler, client, interface, and generated code.
Deleting roughly a thousand lines was a larger source diff than a local validation guard, but it left a smaller long-term attack surface.
Threat model
The vulnerable route was registered only in distributed erasure mode; single-node deployments were unaffected. An attacker needed an internode JWT derived from the root secret, control of a node, or the ability to intercept unencrypted traffic between nodes.
The dangerous fields were inside the msgpack body, not the URL or form data, so upper HTTP path middleware never saw them. xlStorage.ReadMultiple joined and read the resulting paths directly, allowing them to escape the drive root.
This was not an anonymous S3 vulnerability. It crossed the boundary from “cluster root or peer” to “any file readable by the node process.”
First design: retain the API and validate it completely
The initial patch in xlStorage.ReadMultiple:
- rejected absolute paths,
.and..segments, backslashes, Windows drive prefixes, and NUL bytes; - checked final containment across drive, volume, prefix, and file;
- preserved the historical contract for an empty Bucket and
.minio.sys/multipartwhere possible; - returned an error before any read or streaming began.
That design could close the known traversal, but review quickly exposed an early-return gap.
MaxResults exposed the danger of validate-as-you-use
The first patch validated each Files item inside the read loop. For Files=[good, bad] with MaxResults=1, the function returned after reading the first item and never validated the second.
It did not read the second malicious file, but it violated the intended invariant that the entire msgpack request must be valid before execution. Validation was therefore moved into a complete preflight pass, with path-length checks also completed before streaming.
The general rule is useful beyond this endpoint: when a request can return early or stream a partial response, checking an element immediately before use is not equivalent to validating the whole request.
Final decision: delete the API
Further call-chain audit established that:
- upstream removed the last production caller in September 2024;
- multipart had moved to
ReadParts; - the current tree had no in-tree production consumer;
- upstream’s final remediation also deleted
ReadMultiple.
The final change removed the route, handler, client wrapper, StorageAPI / xlStorage method, metric, datatype, and generated code. storageRESTVersion retained the existing compatibility strategy.
| Option | Short-term change | Long-term maintenance surface | Decision |
|---|---|---|---|
| Validate in place | Smaller diff and preserved endpoint | Permanently retains an unused, privileged file-reading API | Abandoned |
| Delete the API | Removes more interface and generated code | Minimizes attack and maintenance surface | Accepted |
Verification and release
The in-place validation phase ran focused tests for xlStorage, the storage-REST client, msgpack encode/decode, and path edge cases; adversarial review exposed the MaxResults flaw. The deletion phase checked the route, client, interface, generated surfaces, and absence of callers.
The public fix is 73ac524, released with SILO 2026-06-18. The post-deletion full suite was not rerun while preparing this article.
Compatibility and claim boundary
- The external S3 API is unchanged.
- Third-party implementations that privately called the internal
/rmplendpoint will stop working. - A mixed-version rolling upgrade may encounter a protocol mismatch, so cluster nodes should be kept on the same version during the upgrade.
- Removing this endpoint proves only that
ReadMultipleno longer exists; it does not establish that every internal node request carrying body paths has completed a containment audit.
This CVE reached the right final fix, but it also leaves an important distinction: closing one endpoint is not the same claim as closing an entire vulnerability class.
3.10 - Internode Path Containment Audit: Paying Off What CVE-2026-42600 Left Owing
Status: Fixed on the local pgsty/minio branch, unreleased and not disclosed (no CVE/GHSA requested; the upstream repository is archived)
Affected scope: Distributed erasure only; cluster-root / internode JWT required
Prerequisite reading: CVE-2026-42600 · ReadMultiple
This article contains complete exploitation vectors and measurements. Publishing it constitutes disclosure. Hold it until the fixed release ships.
The previous entry closed with this sentence:
Deleting the endpoint proves only that
ReadMultipleno longer exists. It cannot be extrapolated into a completed containment audit of every internode body path.
That was an IOU, written down in plain sight. This is the record of paying it — and a not-very-flattering construction log.
Conclusions first
- Twelve defects, all inherited from upstream. Verified by per-function md5 comparison: the fork’s diff against upstream on the affected files is pure deletion, zero added lines.
- This is not a new vulnerability. It is the remainder of CVE-2026-42600 — three more protocol surfaces under the same root cause.
- Our failure is not in the code. It is in the record: a point fix was written up as a closure.
- While fixing it we introduced four regressions of our own, every one of them in a rule we invented rather than reused.
“N endpoints” was the wrong frame
Earlier audits kept counting endpoints, arriving at 19, then 21, then 22 — and missing an entire protocol surface each time. The real structure is four surfaces:
| Protocol surface | Entries | Covered by the global HTTP middleware |
|---|---|---|
| storage-REST HTTP query arguments | 9 | Yes — previously misreported as unprotected |
| storage-REST HTTP msgpack body | 4 | No; r.Form never comes from a body |
| storage Grid RPC | 18 | No; after one upgrade, frames never re-enter the HTTP chain |
| peer-S3 Grid RPC | 5 | No, and it bypasses getStorage() to reach drives directly |
The first row matters as much as the rest: it overturns the earlier “all 21 endpoints escapable” claim. Those audits grepped for the validation helper inside handler bodies, found nothing, and concluded there was no protection — missing that the protection lives in the middleware layer.
The fourth row is the one no amount of hardening in storage-REST handlers can reach.
The root cause is three layers, not one bug
Three design facts, none wrong on its own:
- Validation happens only at the HTTP surface.
r.Formis populated fromurl.ParseQuery(RawQuery)and never from a body (introduced 2017). - Grid RPC bypasses the middleware.
/minio/grid/v1upgrades once; subsequent msgpack frames never re-enter the HTTP chain (introduced 2023). - The storage layer performs no containment.
getVolDirrejects a volume only when it is exactly""/./.., andpathJoinrunsClean(settled 2018).
In one sentence: the upper layer assumes the lower one validates, the lower assumes the upper already did, and neither can see the channel in between.
ReadMultiple was merely one endpoint that exercised that structure. Removing it left the structure intact.
One line of the timeline deserves singling out. The divide-by-zero in ShardFileSize has been present since 2020, but only when it moved inside xioutil.WithDeadline in 2024-10 — a change meant to fix large-object timeouts — did it escalate from “one failed request” to “the whole process exits”, because WithDeadline runs its work function on a bare goroutine that no recover() can reach. That escalation was not visible at the time.
Vectors, confirmed by execution
Every one reproduced against a real xlStorage through the real REST/grid client, with planted sentinel files. None of this is static inference.
| Vector | Surface | Observed |
|---|---|---|
WriteAll("vol","../../x") |
storage grid | arbitrary file write outside the drive root |
RenameFile(".minio.sys","","bucket","x") |
storage grid | the entire system volume (IAM, config) relocated into a readable bucket, with no .. anywhere |
DeleteBucket("../victim", force) |
peer-S3 grid | recursive deletion of a tree outside the drive root |
DeleteBulk("vol","") |
HTTP body | whole volume moved to trash |
ReadAll(volume:"../") |
any | getVolDir’s check defeated by a trailing slash |
CheckParts with a zero Erasure |
storage grid | process terminates |
AppendFile declaring Content-Length: 64 GiB |
HTTP | 68,719,574,840 bytes allocated for an empty body |
DeleteVersions declaring 100M entries |
HTTP | a ten-byte parameter reserved 10.4 GB |
part Size = -2 |
storage grid | a truncated shard reported healthy; heal silently skipped |
Two of these had never been found before and are worth calling out.
RenameFile with an empty source path hits the volume-root alias and relocates the whole volume. Aimed at .minio.sys, one ordinary S3 GET afterwards yields the cluster’s IAM and configuration. It requires no traversal sequence at all — so any audit that greps for .. misses it by construction.
A negative part size floors both terms of ShardFileSize to zero. checkPart’s only integrity test is st.Size() < expectedSize, so every file that exists is reported intact, including a truncated shard. Worse, this holds whether or not the erasure parameters are valid: metadata that passes FileInfo.IsValid() — the very check healing trusts — is affected. That is not an input-validation problem but a data-integrity one: a legitimate heal reading poisoned metadata concludes the shard is fine and skips the repair.
The fix: two chokepoints, not twenty patches
The invariant to restore is one sentence: a path from an internode payload must resolve inside the volume it names, and a volume must resolve inside the drive root.
Only .. can break the first half (absolute and backslash-prefixed paths are folded under volumeDir by pathJoin’s Clean), and the second half has one independent break: paths that alias the volume root. Two rules, therefore — not a policy matrix.
| Chokepoint | Location | Coverage |
|---|---|---|
| Volume axis | getVolDir (4 lines) |
every caller, including peer-S3 |
| Path axis | decorator at getStorage() |
31 remote entries plus nested fields |
Under 40 lines of core logic. No handler is modified, no call site in xl-storage.go is touched, and the local erasure path is left alone.
Two details worth recording:
- The check must run before the join, on the raw argument.
pathJoinrunsCleanagainst an absolutedrivePath, which erases a leading..entirely —/drive/../../etcbecomes/etc, so a check placed after the join reads clean and passes everything. This is the most likely way a future refactor silently undoes the fix. NSScanneris the one method that reaches the filesystem withoutgetVolDir. Its guard line is load-bearing, not decorative.
Among the rejected alternatives, the notable one is adding containment at all 33 pathJoin(volumeDir, …) sinks. That would be genuine defence in depth, but it means 33 edits in the most performance-sensitive file in the tree, each needing its own judgement about whether the volume root is a legitimate target. The guard rails buy most of the same resistance to drift for a fraction of the risk. This is an explicitly recorded IOU: if a code path is ever added that reaches the filesystem without getVolDir, the decision must be revisited.
Construction log: four regressions we caused ourselves
This section is unflattering and more informative than the fix.
First: whitespace. The initial rule treated whitespace as a separator, refusing " " and " " — legal S3 object keys. PutObject commits through RenameData, so such a key would fail on every remote drive simultaneously and break write quorum. The irony: the vulnerability needs root credentials; this bug needs a user to send a space.
Second: backslash-only keys. Same function, same root cause. path.Clean never treats \ as a separator, so on Unix "\\" is an ordinary filename. Refusing it made a distributed cluster reject a write a single-node server accepts — the same S3 API behaving differently by deployment topology.
Third: spaces and periods on Windows. The Win32 normalisation layer strips trailing spaces and periods from a path component, so a component made only of those vanishes and the path resolves to its parent. That makes both " " and "..." volume-root aliases on Windows — and "..." was sitting in our own list of legal object names at the time. We had not merely missed the vector; we had asserted it was safe.
Fourth: negative part sizes. The new guard rejected only “positive size with unusable parameters”, equating “non-zero” with “positive”. Negative values take a different route to the same zero.
Two false greens
Writing the AppendFile acceptance test produced two meaningless green runs in a row:
- Driving it through the REST client — which special-cases
*bytes.Readerand derives Content-Length from it, silently overriding the forged value. - Switching to an opaque reader — at which point Go’s own HTTP client refuses to send a request whose body is shorter than the declared length.
Only driving the handler directly through httptest reproduced it. The lesson: a client’s self-protection is not a server’s defence, and an attacker with a raw socket has no such scruples.
A third was a design failure. We built a per-field reflection poisoner, then discarded it: it cannot distinguish “should have rejected but delegated” from “correctly allowed a non-path field” (ETag, Algorithm, …), so it reports correct behaviour as failure.
The subtlest lived in the fuzzer. The first property test treated separator-only strings as an exception with an early return. That is not an exception, it is a blind spot — the fuzzer had been shut out of the entire category by hand and could never have found the backslash key in a million executions. A wrong exception is more dangerous than no fuzzer at all, because it creates the impression the space has been searched.
A very concentrated pattern
| Component | Where its semantics came from | Regressions |
|---|---|---|
guardPaths |
reused existing hasBadPathComponent |
0 |
getVolDir guard |
reused existing hasBadPathComponent |
0 |
isVolumeRootAlias |
invented | 3 |
guardErasureParams |
invented | 1 |
The reused semantics produced zero regressions; the invented rules produced all of them.
This is not coincidence. hasBadPathComponent is already the object layer’s own rule via IsValidObjectPrefix, validated by real S3 traffic for years, and structurally cannot reject anything creatable through the S3 API. An invented rule has nothing behind it but the author’s imagination.
The actionable form: reuse rather than invent; and when you must invent, write the property test by exclusion rather than enumeration, express exceptions with a predicate independent of the implementation, and keep them as few as possible.
The guard rails matter more than the patch
The final test suite pulls in two directions, and neither alone is enough:
- Falsifiability — remove each guard in turn and confirm the tests actually go red (191 failing subtests with the traversal guards removed; 64 GiB and 10.4 GB reappearing with the allocation guards removed). This is precisely what the rejected community PR lacked: its test asserted
err != nilagainst a target that did not exist, so it passes with the vulnerability fully intact. - Legal-traffic fuzzing — asserting that any key
IsValidObjectNameaccepts, the guards accept (1.96M executions, no violations), and that any legal bucket name survivesgetVolDir(810K). This is what our own first two attempts lacked.
Plus a method-level reflection rail that fails by name when a path-taking method is added to StorageAPI unguarded.
History states the case for these rails bluntly: CVE-2026-39414 was also point-fixed on 2026-04-15 and only received a fix: complete ... two months later. Counting this one, “point fix → recorded as closure → completed months later” has now happened twice in this fork. The problem is not that someone was careless. It is that nothing in the tree could tell you a class was still open. Guard rails turn “someone must remember” into “CI fails”.
On adversarial review
This fix went through five rounds of independent adversarial review. Each round found one missed defect, and all five stood: whitespace keys → backslash keys → the AppendFile allocation → Windows spaces and periods → negative part sizes.
Our own review did find two in the same period (the WithDeadline log amplification and ReadParts using the wrong rule), but only after being pushed to that standard.
The hit rate says something plain: the last gate before merge should be independent acceptance, not the author’s own conclusion. During this work the author judged the change ready to ship four times and was overturned three.
Follow-up status
The first draft listed two implementation gaps. Both are now closed on the local branch, but none of these follow-up commits is in a published server release as of 2026-08-03:
ReadFileHandleris bounded. Commitb6f70ab08rejects a declared read length above 5 GiB, the maximum size of the S3 part represented by this legacy whole-file bitrot path. Legitimate GiB-scale reads can still allocate on that scale; the change removes caller-controlled allocation above the format’s real ceiling rather than pretending large reads are cheap.- Negative part sizes cannot be persisted or trusted. Commit
80e8eaa42rejects them at theAddVersionwrite funnel and again inCheckPartsandVerifyFile, so both new poison and already-written metadata are covered. The internode boundary check uses the same predicate. - Non-positive erasure block sizes are rejected at construction. Commit
80e8eaa42validatesblockSizeinNewErasure, covering the other offset and decode divisions that a single downstreamShardFileSizeguard could not. Rebalance’s separate division is guarded at its own boundary.
Two limitations remain and should not be folded into a stronger claim:
- No Windows CI. Windows builds are published; tests run on Ubuntu only. The Windows rule is reasoned from documented Win32 behaviour and has not been verified on the platform.
- Symlinks. The containment check is lexical, as upstream’s is.
Closing
The previous entry said that closing an endpoint and closing a defect class are two different conclusions. This time the known sinks are closed on the local branch, at the cost of four self-inflicted regressions and three overturned declarations that it was ready to ship. Publication remains a separate gate: the fixes above are not in a released server build yet.
If only one sentence survives: the vulnerability was upstream’s; our mistake was treating a point fix as a closure. And what prevents a third occurrence is not a more careful person — it is a test that fails.
3.11 - The Parser Knew, the Schema Didn't: Config Keys That Could Take Every Notification Down
Status: Fixed on the local pgsty/minio branch as 162ded343, unreleased
Classification: Configuration-schema consistency and availability, not a vulnerability; includes one defensive hardening (credential values no longer echoed in validation errors)
Affected scope: notify_nats JWT/NKey/TLS-handshake-first options, notify_amqp immediate, and any pre-2020 config migrated with an enabled NATS target — whose failure then silences every notification backend
Tracking: pgsty/minio issue #39
This article names two unfixed availability defects in neighbouring code (the Postgres/MySQL migration writes, and
kvFieldstypo folding). Neither is exploitable — both break the operator’s own configuration, loudly or not at all — and both are already named in the committed audit test’s allowlist. Publication needs no hold beyond the release itself.
Conclusions first
- Three
notify_natsoptions —user_credentials,nkey_seed,tls_handshake_first— and onenotify_amqpoption —immediate— were read by the parser, written by the legacy migration, and registered nowhere.CheckValidKeysrejected exactly whatGetNotifyNATSrequired. - One constant meant two things.
target.NATSUserCredentialsheld the string"MINIO_NOTIFY_NATS_USER_CREDENTIALS", sat in the environment-variable const block, and was used both as an env var name and as a config key. The snake_case config key for creds-file auth did not exist anywhere in the program. - The reporter’s error did not come from their command. It came from the legacy migration: the pre-fix migration reproduces the issue’s error text byte for byte, including the
notify_nats:ONEtarget name their command never mentioned. The migration wrote the store once; validation rejects it at every boot thereafter. - The blast radius is the amplifier:
FetchEnabledTargetsfails fast on the first bad subsystem, its only caller just logs, and the global target list staysnil— so one broken NATS entry silently switches off Kafka, webhook, MQTT, and everything else. - Inherited from upstream. Three feature PRs — #19139 (2024-02,
user_credentials), #21008 (2025-04,tls_handshake_first), #21231 (2025-04,nkey_seed) — each added the parser and the env var, and each skipped the schema. Upstream is archived; the fork inherits both the defect and the duty. - The fix registers the keys, splits the two-faced constant, corrects the migration — including a sibling bug that silently wrote
immediate’s value under theinternalkey — tolerates the legacy on-disk spelling on the load path only, stops echoing values in invalid-key errors in bothCheckValidKeysforms, and installs an AST audit that mechanically forbids this defect class across all ten notify subsystems. - The audit found the next instance before the ink dried: the Postgres/MySQL legacy migrations write five unregistered keys, one of which is a plaintext database password. Recorded, allowlisted shrink-only, tracked for follow-up.
The error that named a target nobody asked about
The report (issue #39, by kuldeep-link11, against a NATS cluster using JWT operator/accounts auth) is a clean reproduction: configure notify_nats with a credentials file, watch it bounce.
Two things in that error are wrong in ways the command cannot explain. The invalid-key list contains nkey_seed= and tls_handshake_first=off — keys the user never passed. And the rejected sub-system is notify_nats:ONE, while the command configured notify_nats:FITCHECK.
The second oddity is the whole case. Our reviewer established that the mc admin config set path cannot even carry an unregistered key: the server-side tokenizer, kvFields, splits the input line by searching for registered key names, so an unknown token never becomes a key at all — it is absorbed into the preceding key’s value. Probed directly:
So the rejection could not have been about the command line. It was validateConfig sweeping the whole subsystem and tripping over a different, already-stored target named ONE that carried all three keys. Only one code path in the tree writes those key names into a store: the legacy config migration. Driving the pre-fix migration on an enabled NATS target named ONE reproduces the issue’s error text character for character — including the empty nkey_seed=, which is just what migration writes when the legacy config had no NKey.
That reframes the incident. This was not “the server rejected my command.” It was: an old config was migrated once, the migration wrote three keys the validator does not accept, and the store has been failing validation at every boot since — taking every other notification target down with it, silently, because the failure is logged and swallowed. The reporter’s command merely walked into the blast radius and got handed someone else’s error.
One constant, two meanings
The declaration, as inherited (internal/event/target/nats.go, pre-fix):
NATSUserCredentials is named like a config key, valued like an env var, and shelved with the env vars. The parser used it as both: once as the env var to look up, once as the config key to read from the stored KVS. The migration used it as a key to write. There was no "user_credentials" string anywhere in the program — the config key for creds-file auth simply did not exist, which is why the reporter, finding no documented key, resorted to passing the env var name as one.
A name that means two things will eventually be wrong in one of them. Here it was wrong in both directions at once: as a key it was unregistered garbage; as the only spelling available it taught users and the migration to write garbage.
Four surfaces, no handshake
A notify option in this codebase lives on four surfaces that must agree: the defaults (DefaultNATSKVS — what validation accepts and mc admin config get displays), the help (HelpNATS — what mc admin config documents), the parser (GetNotifyNATS — what the server actually reads), and the migration (SetNotifyNATS — what upgrades write). Nothing ties them together. Three upstream feature PRs each updated the parser and the env plumbing, and each forgot the first two surfaces:
| Key | Parser reads | Migration writes | Defaults | Help | Introduced |
|---|---|---|---|---|---|
user_credentials |
yes (via the two-faced constant) | yes (as the env-name string) | no | no | #19139, 2024-02 |
nkey_seed |
yes | yes | no | no | #21231, 2025-04 |
tls_handshake_first |
yes | yes | no | no | #21008, 2025-04 |
immediate (AMQP) |
yes | see below | no | no | config-KV rewrite era |
The AMQP row hides the quieter sibling. The AMQP migration did not skip immediate — it wrote immediate’s value under the internal key, and dropped cfg.Internal entirely:
Because internal is registered, this one passes validation. The NATS gaps break a migrated config loudly enough to be found eventually; the AMQP gap corrupts it silently — a migrated broker config carries the wrong flag with a clean bill of health. One defect class, two presentations: the unregistered key fails closed, the misrouted value fails wrong.
The amplifier
None of this would deserve the word “outage” without the aggregation semantics. FetchEnabledTargets iterates the ten notify subsystems and returns (nil, err) on the first failure; its only caller logs the error and moves on, leaving the global notification target list nil; every later lookup nil-guards into an empty list. One rejected notify_nats target therefore turns off all bucket notifications — Kafka, webhook, AMQP, MQTT, the lot — with nothing but one line in the server log.
We considered changing this to per-subsystem isolation and decided not to, in this fix. Skip-the-broken-subsystem is a real behavioural change to how operators experience a bad config: today it fails loudly-in-aggregate (everything stops), and configurations that operators have already reasoned about depend on validation being all-or-nothing. Rewiring that is a compatibility decision that deserves its own change, not a rider on a registration fix — and once registration is correct, legal configs no longer trigger the cascade at all. The decision is recorded as a doc comment on FetchEnabledTargets and pinned by a characterization test, so the next person to touch it changes it on purpose or not at all.
The fix
About a hundred lines of production change, carried by nine hundred lines of tests (162ded343: 8 files, +1029/−7).
Registration. All four keys enter their default KVS and help schema, placed where an operator would look for them (user_credentials beside username, nkey_seed after token, tls_handshake_first after tls_skip_verify, immediate beside mandatory). Registration is also what makes a key visible: all four now appear in mc admin config get output where they previously did not.
The constant, split. NATSUserCredentials becomes a real config key, "user_credentials"; a new EnvNATSUserCredentials carries the env string. Every env var name involved — MINIO_NOTIFY_NATS_USER_CREDENTIALS, _NKEY_SEED, _TLS_HANDSHAKE_FIRST, MINIO_NOTIFY_AMQP_IMMEDIATE, and their _TARGET-suffixed forms — is frozen byte-for-byte: they are public interface, they worked throughout (the env route was always the workaround), and a test now pins them as raw string literals, so no rename of a Go constant can drift them silently.
Help flags, by precedent. Both new NATS values are file paths (a .creds file; an NKey seed file), so they are marked Sensitive but not Secret, mirroring cert_authority/client_cert/client_key rather than password/token. Secret would additionally redact them from mc admin config get — hiding an operator’s own configured path from them, which is why the private-key path client_key never had it either.
Migration, corrected. SetNotifyNATS now writes the real key; SetNotifyAMQP writes immediate = cfg.Immediate and internal = cfg.Internal.
If you are affected today, on a pre-fix build: the env var route works and always did, and mc admin config reset myminio notify_nats:<target> un-wedges a poisoned store at the cost of its settings. On the fixed build, poisoned stores simply load again — next section.
Living with what the old migration already wrote
Fixing the migration helps the next upgrade. It does nothing for stores the broken migration already wrote, which contain the literal key MINIO_NOTIFY_NATS_USER_CREDENTIALS — still unregistered, still fatal at every boot. Telling those operators to hand-reset their config would mean punishing them for our write.
So the load path tolerates it, narrowly. Validation accepts the legacy spelling for the NATS subsystem only — a test asserts AMQP still rejects it, so the tolerance cannot become a general escape hatch — and the parser falls back to it only when the real key is empty. Precedence is env > user_credentials > legacy key, and it holds by construction rather than by convention: the fallback result is passed as the default argument of the env lookup. All three orderings are tested. The legacy key stays out of the defaults and the help on purpose: it is tolerated, never advertised, never newly settable (kvFields sees to that).
The constant for it is a package-local literal, not an alias of EnvNATSUserCredentials — deliberately. It names bytes already on disk, so it must not follow any future rename of the env constant. The comment says so.
One trap discovered while wiring this, worth its own paragraph because it will bite someone eventually: the codebase has two CheckValidKeys — a free function and a method — and their deprecatedKeys parameters mean opposite things. The free function tolerates the listed keys (skips them); the method subtracts them from the valid set (rejects them). Refactoring this call from one form to the other would silently invert the tolerance into a ban. That asymmetry is now documented at the call site, which is the best one can do short of renaming an exported API.
The tolerance is written to be retired: the clean end state is to rewrite the legacy key into user_credentials once at load, then delete both the tolerance and the fallback. That is follow-up #2 below — and it also closes a small hole the tolerance leaves open: an unregistered key carries no Sensitive flag, so a tolerated legacy key ships its value (a path) unredacted in health-diagnostics bundles while user_credentials shows *redacted*.
Secrets in error messages
The invalid-keys error that started all this printed the rejected pairs with their values: found invalid keys (MINIO_NOTIFY_NATS_USER_CREDENTIALS=/jwt/creds/minio_notifier.creds ...). Those paths are mild. The mechanism is not: whatever value rides on a rejected key — a mistyped nkey_sed=<seed>, a bind password on a stale LDAP key — lands in the server log and in the mc client’s terminal.
Both CheckValidKeys forms now print key names only, keeping the shape and the mc admin config reset hint. The second site was one step beyond the written task scope — the method form serves LDAP, OpenID, and the policy plugin, where a rejected value can be an actual bind password — and the independent review, asked to judge that extension, said it would have demanded it: fixing one of two identical leaks is a half-fix. Repo-wide, nothing parsed values out of that string and no test asserted the old text; the change is global and intended.
There is a converse worth recording: this redaction is what stands between the next defect of this class and a credential in the logs. The follow-up below found the Postgres/MySQL migrations writing a plaintext database password under an unregistered key — on a pre-redaction build, the resulting rejection prints that password.
A guard that makes the class extinct
Registering four keys fixes four keys. The class — four surfaces, no handshake — stays open unless something ties the surfaces together mechanically. The fix therefore ships an AST-based audit test that parses parse.go and legacy.go, resolves the constants (from the target package sources, so there is no hand-maintained list to rot), and asserts, for all ten notify subsystems:
- every key the parser reads is registered in that subsystem’s defaults;
- every key the migration writes is registered (minus an explicit, shrink-only allowlist — next section);
- every help entry names a registered key.
Run against the pre-fix tree, it fails on exactly the four known gaps and nothing else — which is the red proof that it measures the right thing.
The adversarial review then attacked the audit itself with a mutation harness, on the theory that a guard you cannot watch fail is a guess — the discipline the previous article argued for. Seven of its nine mutations were caught. Two were not, and both blinded the audit silently: rename the parser’s loop variable (the read-collector pattern-matched the receiver name kv), or switch a migration entry to Go’s idiomatic elided composite-literal form (the write-collector demanded a typed config.KV{...}). In both cases the collector returns an empty map, the assertion loop iterates zero keys, and the test passes vacuously. Both are refactors a maintainer would make without a second thought; one of them is what gofumpt nudges you toward.
Two hardenings closed this, each verified in both directions — with the hardening the mutation is caught; with the hardening removed (the counterfactual) the vacuous pass returns:
- A floor assertion in the reverse direction: every registered key must be seen being read. This holds for all ten subsystems today — measured, not assumed, including the deprecated
streaming_*keys read inside a nested conditional — so it costs nothing, and a blinded collector now produces one loud error per registered key (22 of them for NATS) instead of a green run. - A widened literal guard: typed literals that are neither
config.KVnorconfig.KVSare skipped; untyped (elided) literals have no type to check and are now inspected rather than ignored.
Final score: ten mutations, ten caught — the harness gained one variant along the way, and the closure round swept the full suite. The audit also enforces its own allowlist in both directions — removing an entry that is still needed fails, and an entry that goes stale (the migration no longer writes that key) fails too, so the allowlist can neither grow silently nor lie about the present.
What the audit found next
The write-side check refused to go green on two subsystems that had nothing to do with issue #39. SetNotifyPostgres and SetNotifyMySQL write five keys — host, port, username, password, database — that no default KVS registers and no parser reads. These are relics of the pre-DSN configuration shape, and the migration still emits them. Driving the real helpers confirms it: a migrated Postgres or MySQL notify target is rejected on the next load with found invalid keys (host, port, username, password, database) — the same failure mode as NATS, the same every-boot persistence, the same all-notifications blast radius through the fail-fast. And password there is a plaintext database password, which is exactly the value the redaction above now keeps out of the logs.
It is deliberately not fixed in this change. The scope was locked to the NATS and AMQP gaps, and the right treatment (register the five as deprecated, or stop writing them, or both) is a judgment call that deserves its own red/green cycle. It is pinned in the audit’s knownUnregisteredWrites allowlist with a shrink-only comment, so it cannot be quietly forgotten: the day someone fixes it, the stale allowlist entry fails the test and demands its own deletion.
Open items, none in a released build as of 2026-08-04:
- Postgres/MySQL migration unregistered writes — major, live at every boot for anyone migrating a pre-KV config with those targets enabled.
- Rewrite-on-load for the legacy NATS key, then retire the F5 tolerance and fallback; also closes the health-bundle redaction gap for tolerated keys.
kvFieldstypo folding — an unknown key name inmc admin config setis silently absorbed into the preceding key’s value instead of erroring. Pre-existing upstream wart; it protected nobody here and will corrupt someone’ssubjecteventually.
Review record
The change went through three gates before commit:
| Gate | Method | Outcome |
|---|---|---|
| Implementer | tests written first and run against the unmodified tree; the missing constant made the suite fail to compile, which is itself the red for the split; targeted reversal produced runtime reds for the rest | red established for every claim |
| Independent adversarial reviewer | detached worktree at the pre-fix commit; re-derived every red rather than trusting the report; mutation harness against the audit; probe tests for precedence edges; reproduced the reporter’s error byte-for-byte from the migration path | REVISE, two demands |
| Closure round | both demands applied; counterfactual mutation runs (with and without each hardening) prove the hardenings load-bearing; reviewer re-diffed, re-ran, re-mutated | ACCEPT, 10/10 |
The honest accounting, in the house tradition: neither demand was a defect in the production fix. One was a lint gate (two British spellings that would have failed make test — and while fixing them, the implementer’s rewritten comment introduced a third, dialled, which the same gate caught; the demand vindicated itself in real time). The other was the audit-blindness pair above — durability of the guard, not correctness of the change. What the review did overturn was the incident’s origin story: the migration-path reproduction, the ONE target, and the kvFields absorption proof all came from the reviewer, and they change what operators should conclude — this was a boot-time outage lying in wait in stored configs, not a CLI validation quirk.
The implementer’s red phase also surfaced three bugs in its own new tests before the fix landed, recorded rather than smoothed over: a fixture that assumed stored targets are layered over defaults when config.Merge actually passes them through verbatim; a characterization test that segfaulted on a nil HTTP transport (FetchEnabledTargets dereferences it unconditionally — hostile to testing, noted, unfixed); and an early draft keyed to the very constant the fix renames, which made it pass green pre-fix — rewritten against the literal string so it pins the on-disk schema rather than the Go symbol.
Declined, and left open
Declined, deliberately:
- Per-subsystem error isolation in
FetchEnabledTargets— a compatibility decision, not a rider (above). - Registering or advertising the legacy key — tolerated on load, absent from defaults and help, impossible to set anew.
- Renaming
EnvNatsTLSHandshakeFirst’s odd casing — an aesthetic rename in a fork is diff noise that buys nothing. - Fixing the Postgres/MySQL migration here — scope-locked, pinned in the allowlist instead (above).
Left open: the three follow-ups above, and one cosmetic consequence — a store that still carries the tolerated legacy key will show it verbatim in mc admin config get until rewrite-on-load lands.
Closing
Every one of these keys worked perfectly through the environment variable, which is why three feature PRs could ship, get reviewed, get used, and never notice that the config-file half of the interface was stillborn. The parser and the schema are two descriptions of the same contract, maintained by hand, four surfaces wide — and for two and a half years nothing in the build checked that they agree.
If only one sentence survives: when two artifacts must stay identical and only convention binds them, the divergence is not a risk but a schedule — put a machine between them, then mutate the machine until you have watched it catch the drift you fear.
3.12 - Object Grant, Bucket Reach: When 'bucket/*' Could Rewrite the Bucket Itself
Status: Fixed on pgsty/silo-pkg main (3c24ad1, extended by 1f97549, scoped to its final twelve actions in d8b1fa7), released as silo-pkg v3.11.0; consumed by pgsty/minio
Classification: Access-control hardening — a privilege boundary, narrowly restored
Affected scope: IAM users/roles/service accounts granted only object-scoped (arn:aws:s3:::bucket/*) access, in deployments that share a cluster across tenants
Tracking: upstream minio/minio issue #20449 (public since 2024, still open)
Conclusions first
- In IAM policy matching, a bucket-level request carries an empty object name, and the matcher built its resource string as
"bucket/". An object-only policy pattern —"arn:aws:s3:::bucket/*"— then matched that string, so a grant that should cover only objects also authorized bucket-level actions. - The dangerous one is
PutBucketPolicy. A tenant holding onlys3:*onbucket/*could install a bucket policy withPrincipal:"*"— making the bucket publicly readable or writable — or grant itself bucket-level control. Same mechanism, same class:DeleteBucket/ForceDeleteBucket(the issue’s own reproduction),PutReplicationConfiguration(exfiltration),PutBucketLifecycle(mass deletion),PutBucketVersioning,PutBucketObjectLockConfiguration, and the rest of the bucket-configuration writes. - The full correction is a two-directional behavior change: it tightens over-granting
Allowstatements and loosens over-blockingDenystatements, and it would revokeListBucket/GetBucketLocationgrants that many real deployments write asbucket/*today. That is a compatibility break, not a clean patch. - So we shipped a narrow fix — first six sensitive bucket-configuration writes, then, in a second pass, twelve: the bucket-level writes that hand the caller something its object access does not already give it, plus four that no handler implements. Only on
Allowstatements, so noDenyand noNotResourceexclusion is ever weakened, with an environment-variable escape hatch. The compatibility-sensitive read/list family,CreateBucket, and three bucket writes with plausible tenant use are left unchanged, by decision. - Twice we claimed the change could only remove permissions, and twice an untested case said otherwise — the second time found by an independent review of a shipped release. The protected path now requires the resource to match both the bare and the historical form, which makes the property hold by construction rather than by argument.
- The fix is red/green proven at the matcher layer and end to end through the real handlers; the object-scoped hot path is untouched.
The slash, and the empty object name
Every bucket-level S3 operation authorizes with an empty object name — checkRequestAuthType(ctx, r, policy.PutBucketPolicyAction, bucket, ""). The IAM matcher turned that into a resource string, and for the empty-object case it appended a trailing slash:
"bucket/" is matched by the wildcard pattern "bucket/*", because * matches the empty string. So a policy that grants s3:* on arn:aws:s3:::bucket/* — which reads as “anything, but only on the objects in bucket” — was evaluated as granting bucket-level actions too. The bucket-policy evaluation path (for anonymous/public access) never had this slash and is the reference-correct behavior; only the IAM path was wrong, and there was exactly one place it went wrong.
This is upstream minio/minio #20449, filed in 2024. An early upstream attempt deleted the slash outright and was reverted the same day for breaking policies that relied on the old behavior. The lesson we took from that revert shaped the fix below.
What it actually enables
PutBucketPolicyHandler has a single authorization gate and nothing behind it. Once the IAM check passes, the caller may store any well-formed bucket policy for that bucket.
The concrete chain, in a multi-tenant cluster:
- An administrator grants tenant A the policy
Allow s3:* on arn:aws:s3:::bucket-a/*, intending “A may work with the objects inbucket-a, nothing more.” - Because of the slash, A may call
PutBucketPolicyonbucket-a. - A installs
{ "Principal": "*", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::bucket-a/*" }. Every object inbucket-ais now readable by the anonymous internet.s3:*makes it world-writable. PointingPrincipalat an account A controls exfiltrates the data; granting itself bucket-level actions in that policy is self-escalation.
The same object-only grant reaches other bucket-configuration writes with comparable consequences: replication to an attacker’s target, a one-day lifecycle expiry that deletes the bucket’s contents, disabling versioning, tampering with object-lock retention. None of these should be reachable from a grant scoped to objects.
This is not remotely exploitable and requires no missing credential — the caller is an authenticated principal you deliberately gave a scoped policy to. In a single-tenant deployment, that principal is your own trusted user and the practical risk is low. In a shared, multi-tenant cluster it is a real cross-tenant boundary failure.
Why a narrow fix, not the whole boundary
The obvious fix is to stop appending the slash for every bucket-level request. We did not do that, for two reasons that matter more than the one-line diff suggests.
It breaks common, benign usage. The correction does not only revoke the dangerous bucket writes — it also revokes ListBucket, GetBucketLocation, and ListBucketMultipartUploads when they were granted through bucket/*. Many deployments write exactly that and rely on it. The evidence is upstream’s own test suite: eleven STS integration tests grant s3:ListBucket on bucket/* and then assert that listing works. If the projects that wrote the server write it this way, production policies do too. A maintenance upgrade that turns those into AccessDenied is precisely the kind of surprise we refuse to ship.
It cuts both directions. The matcher builds the same resource string for Allow and Deny. So the full correction tightens over-granting Allow statements and simultaneously loosens over-blocking Deny statements: an administrator who locked a bucket with Deny s3:* on bucket/* would silently lose that protection for bucket-level actions. A clean-looking fix that moves security in two directions at once is not a maintenance patch — it is a migration.
So we narrowed the change to where it is unambiguously right and effectively free of compatibility cost:
- Only bucket-level writes are protected. The first pass covered six sensitive configuration writes:
PutBucketPolicy,DeleteBucketPolicy,PutReplicationConfiguration,PutBucketLifecycle,PutBucketVersioning,PutBucketObjectLockConfiguration. The second pass (below) extended that to twelve. Almost nobody grants these through an object-only pattern on purpose — you do not accidentally rely on an object grant being able to rewrite a bucket’s policy or delete the bucket — so revoking that path breaks essentially no one. - Only on
Allowstatements.Denystatements keep the historical resource string, so no existingDenyis ever weakened. The narrow fix only ever adds a denial. - The read/list family is left exactly as it was.
ListBucketonbucket/*still works. That is the compatibility-sensitive part, and it waits.
The fix
The matcher keeps the trailing slash in every case except one: a bucket-level Allow statement being evaluated for a protected action, with the compatibility shim off.
Because args.Action is the concrete request action, a wildcard grant (s3:*) is covered too: the wildcard matches at the action step, and by the time the resource string is built the action is the specific PutBucketPolicy. A bare-bucket resource (arn:aws:s3:::bucket) and the * resource still match, so correctly scoped grants — including the built-in readwrite policy — are untouched.
The escape hatch is MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on, read once at startup. It restores the full historical behavior — both the over-grant and the over-block — for any operator who needs the old semantics while they adjust their policies.
The second pass, and the question that decided its size
The first round protected six configuration writes and stopped. Reviewing it against the original issue showed that was not enough: the action reproduced in #20449 itself — DeleteBucket — was still reachable through an object-only grant. An end-to-end test against the first-pass build confirmed it: a user holding nothing but s3:* on arn:aws:s3:::bucket/* called RemoveBucket and the bucket was gone.
Extending the set raised the real question — how far? The first instinct was “every bucket-only write except CreateBucket,” fifteen actions. That was the wrong instinct, and the reason is a detail of how the bug fires.
The bug only triggers when the statement already grants the bucket-level action. Resource matching runs after action matching, so a read-only tenant holding s3:GetObject on bucket/* never reaches DeleteBucket — the action never matched. In practice the affected principal holds s3:*, which means they already have full read, write, and delete over every object in the bucket. That reframes the severity of each candidate action, because the question is not “how dangerous is this action in the abstract” but “what does reaching it add to a position that already includes all the data?”
By that test, three groups fall out:
Protected — reaching these grants something the object access does not. PutBucketPolicy and DeleteBucketPolicy hand access to other principals, anonymous included, and can grant the caller bucket-level actions it was never given: self-escalation and public exposure. PutBucketObjectLockConfiguration and PutBucketVersioning defeat protections that exist precisely to stop a holder of write access from destroying data. PutReplicationConfiguration and PutBucketLifecycle act under server credentials and keep acting after the caller’s access is revoked. DeleteBucket and ForceDeleteBucket destroy the bucket entity and its configuration irreversibly.
Protected at zero cost. PutBucketCors, DeleteBucketCors, PutBucketQOS, and PutInventoryConfiguration have no MinIO server behavior attached today — no handler at all, or a handler that returns NotImplemented after the authorization check. Withholding them changes nothing that works, and covers them in advance if a handler is ever wired.
Deliberately not protected. PutBucketTagging, PutBucketEncryption, and PutBucketNotification are bucket-level writes, and the first draft of this pass did protect them. They came back out. None of the three gives the caller access it does not already hold — the harm is to the owner’s posture, not to the access boundary — while a tenant handed s3:* on bucket/* and told “this bucket is yours” may quite reasonably tag it, set default encryption, or wire up event notifications. Low security gain against a real compatibility cost is the wrong trade for a maintenance release. They keep the historical matching, and a test now asserts that they are unprotected, so putting any of them back is a deliberate act with a visible cost rather than an edit to a list.
That leaves twelve actions, shipped as silo-pkg v3.11.0. Two older boundaries stand unchanged: CreateBucket keeps the historical matching (it targets a bucket that does not exist yet, and provisioning flows commonly create a tenant’s bucket with that tenant’s own credentials), and the read/list family still waits for the migration-gated change.
The choice of what to break, in other words, was made by asking would an administrator ever write this on purpose — not by ranking the actions by how dangerous they sound. The first question predicts which upgrades break; the second only sets urgency.
The claim that was wrong twice
Everything above rests on one property: this change may remove permissions and must never add one. Both times we asserted it, we were asserting it about a mechanism we had reasoned through rather than tested through. Both times it was false.
The first pass withheld the slash from the NotResource match as well — and NotResource is an exclusion. An Allow s3:* NotResource bucket/* statement historically did not apply to bucket-level requests on that bucket; matching the exclusion against the bare bucket name made it stop matching, so the Allow it qualified grew, for exactly the writes being protected. Restoring the historical form for NotResource fixed that, and the second pass shipped saying the result was “provably monotone.”
An independent adversarial review of that release produced a counterexample within the hour. Withholding the slash does not merely remove a match — it changes which string patterns are matched against, and a pattern can match "mybucket" without ever having matched "mybucket/". The clean case is a fixed-width wildcard:
? matches exactly one character. Against the historical nine-character "mybucket/" it does not match, so this statement never authorized the bucket-level write. Against the new eight-character "mybucket" it matches, so the hardening granted something the buggy matcher refused. Small in reach — you have to write a length-sensitive pattern — but it is precisely the class of defect the property was supposed to exclude, shipped in a release whose notes claimed the property held.
The fix is not another special case. On the protected path the matcher now requires both forms to match: the bare bucket name and the historical "bucket/". The result is an intersection with the historical decision, so it is monotone by construction — there is no pattern it can newly satisfy, and no argument to get wrong next time. mybucket* still grants (it matched both all along); mybucket/* is still withheld; mybucke? is refused exactly as it always was. That shipped as silo-pkg v3.11.0.
Two things are worth taking from this beyond the patch itself. A correctness fix in an authorization path must never make anything newly allowed — and the only way to know is to test both directions, because the reasoning feels airtight in both cases where it wasn’t. And when a security property is load-bearing, build it out of an operation that cannot violate it rather than out of a case analysis you believe is complete.
Regression tests now pin each direction: grants narrowed, Deny untouched, NotResource exclusions untouched, fixed-width wildcards not broadened, the three unprotected writes still reachable, plus an invariant test that every protected action really is bucket-only (ResetBucketReplicationState, despite its name, is an object action and stays out). In the server they run end to end through the real handlers — client, inline session policy, and the S3 router — and every one of them fails against the release that had the bug.
What you will notice
For nearly everyone: nothing. Object access is unchanged, ListBucket via bucket/* is unchanged, and correctly written bucket policies are unchanged.
The one visible change: a request that tries to delete the bucket, or change its policy, replication, lifecycle, versioning, or object-lock configuration using credentials whose only matching grant is an object-only bucket/* pattern now returns AccessDenied. Bucket tagging, default encryption, and event notification are not affected. That is the boundary being enforced. If a deployment genuinely depends on the old behavior, set MINIO_API_LEGACY_BUCKET_RESOURCE_MATCH=on and grant those actions on the bare bucket ARN (arn:aws:s3:::bucket) at your own pace.
What we deliberately left open
The general problem in #20449 — that bucket/* reaches the remaining bucket-level actions: ListBucket, GetBucketLocation, the configuration reads, CreateBucket, and the three tenant-plausible writes above — is not fixed here. Closing it fully means revoking grants that real deployments depend on, so it belongs to a future release that carries a migration path.
What that release owes operators is more than a wider action list, because no one can enumerate every deployment’s policies — which means shrinking or growing the protected set by guessing is an exercise with a hard ceiling. Three things raise it:
- A startup policy audit. Walk the stored policies and name each one whose meaning changes, in both the grant and the deny direction. That turns an upgrade surprise into a pre-upgrade checklist, it is read-only, and it can ship before the enforcement change rather than with it.
- A denial that explains itself. When a request is refused because only an object-scoped grant matched, say exactly that, and name the compatibility switch. A break an operator can diagnose in thirty seconds costs an order of magnitude less than a silent one.
- A switch with a scope.
MINIO_API_LEGACY_BUCKET_RESOURCE_MATCHis all-or-nothing today: an operator who needs one action back has to reopen the self-escalation path along with it. Per-action scoping is what makes the change safe to adopt.
Recording the boundary rather than implying it: today twelve bucket-level writes are corrected. Everything else — the read/list family, CreateBucket, and bucket tagging, encryption, and notification — still honors bucket/* as a bucket-level grant, by decision, until that migration-gated change lands.
Closing
A single appended slash turned “only the objects” into “and the bucket too.” The tempting fix removes the slash everywhere and, in doing so, breaks a listing pattern half the world relies on and quietly weakens every Deny written against bucket/*. The fix we shipped removes it in exactly the place where an object-scoped grant should never have reached — the writes that can make a bucket public, and the ones that can delete it — and nowhere else. The rest is written down, waiting for a release where breaking it is something users are told to expect rather than something that happens to them.
3.13 - Absent Is Not Empty: A Blank versionid and the Fail-Open It Invites
Status: Fixed on the local pgsty/minio branch as 744a9dcd7, unreleased
Classification: Policy-enforcement correctness — a fail-closed report, a fail-open trap avoided, and one narrow trim bypass closed. Not a headline CVE — see How we classify this
Affected scope: Any deployment with a bucket/IAM policy using Null or StringEquals on s3:versionid; the reported break is on DeleteObject/DeleteObjects
Tracking: upstream minio/minio issue #21735 (reporter iTrooz, 2026-01-10); upstream repository archived read-only since 2026-04-25
This article documents an unreleased fix and two unfixed same-class residuals in neighbouring paths (governance-bypass and Snowball). Hold publication until the fix ships and the residuals are triaged.
Conclusions first
- The policy engine decides
Nullby slice length, not by content. MinIO wrote"versionid": {""}into the condition map unconditionally, so a request that named no version still presented a length-1 slice.Null:{s3:versionid:true}— “match only when the key is absent” — could therefore never match, andNull:falsealways matched. The reporter’s “allow deletes only of the current object” policy denied every current-object delete (HTTP 200 envelope, per-objectAccessDenied). - The one-line fix is a trap. “Write the key only when it is non-empty” fixes the report and simultaneously opens something worse.
DeleteObjectscarries each object’s version in the XML body; the condition builder reads only the query string. Drop the empty key and a body version simply vanishes from the map — read as absent, i.e. as null — so a policy meant to protect old versions would authorize deleting a specific one. Fail-closed defect, meet fail-open bypass. - The real fix has two parts: write the key only when a version is named, and bind it, for
DeleteObject, to the effective server-resolved version (ReqInfo.VersionID) — the per-entry body value that the DeleteObjects loop already resolves — rather than to whatever the query string happened to carry. - A third, adjacent hole closed on the way: the builder read the version untrimmed while the object layer trims it, so a padded
?versionId=V%20let aDeny StringEquals s3:versionid "V"be sidestepped on the read/tag/copy paths. - Inherited from upstream, and unfixable there.
minio/miniois archived read-only, so the fix lives in the fork; this is the samegetConditionValueswe hardened in the condition-source work.
Absent is not empty
A condition key in a MinIO policy resolves to a lowercase name in a map[string][]string, and the engine answers Null by asking how long that slice is (silo-pkg .../policy/condition/nullfunc.go):
The content of the strings is never read. A slice {""} has length 1. To this function, a present-but-empty value is indistinguishable from a real version ID, and both are the opposite of absent.
Now the value that fed it, as inherited (cmd/bucket-policy.go, getConditionValues):
vid is the request’s ?versionId, empty on the overwhelming majority of calls. So every request, versioned or not, arrived at the engine carrying versionid: [""] — permanently length-1, permanently “present.”
The two Null directions then invert:
| Request | Map state | Null:true (want absent) |
Null:false (want present) |
|---|---|---|---|
| no version named | {""} (len 1) |
false — never matches | true — always matches |
?versionId=abc |
{"abc"} (len 1) |
false | true |
| (correct behaviour) no version | absent (len 0) | true | false |
The reporter wrote the canonical “let clients delete current objects but not roll back versions” policy — Allow s3:DeleteObject with Condition {"Null": {"s3:versionid": "true"}} — and watched every version-less delete return AccessDenied. The Allow never fired because its condition tested “no version named” and the map insisted a version was always named. StringEquals cannot see the difference either ({""} and absent both fail to intersect a non-empty policy value); only Null and ForAllValues:* are sensitive to it, which is why Null is where it surfaced.
The fail-open next door
The obvious fix writes the key only when it is non-empty, and for a single DeleteObject that is completely correct: no version → absent → Null:true matches. Ship that alone, though, and Multi-Delete turns it into an authorization bypass.
DeleteObjects (POST /{bucket}?delete) does not put versions in the query. Each object carries its own optional version in the request body:
The condition builder reads r.Form — the query string — and nothing merges an XML body into it. So under the naive fix, an entry that names version a1b2… in the body produces an empty query version, the key is omitted, and the engine sees absent — null. A policy written to allow only null-version deletes now matches, and the specific old version the operator meant to protect is deleted. The fail-closed nuisance from the report has become a fail-open on exactly the operation that most needs to be scoped per object.
This is the crux the reporter’s simple case hides: the condition value must be the version the server will actually act on for this object, and for Multi-Delete that value lives on a channel the condition builder never looked at.
The fix: the effective version, not a convenient one
Two mechanisms, because either alone is wrong.
1 — Represent absence honestly (cmd/bucket-policy.go). Write the key only when the request names a version, so “no version” becomes a length-0 read:
2 — Bind DeleteObject to the effective version (cmd/auth-handler.go, authorizeRequestWithTags). The DeleteObjects loop already resolves each entry’s body version into ReqInfo.VersionID (via checkRequestAuthTypeWithVID, cmd/bucket-handlers.go:502, a sequential loop — no shared-state race). Authorization rebinds the condition value to that server-resolved string, and deletes the key when it is empty:
An end-to-end test drives a &versionId=query-level-decoy on the DeleteObjects URL and asserts it never reaches any entry’s decision — the per-entry body value wins, the decoy is stripped.
Why DeleteObjectAction only, and not a blanket ReqInfo rebind. The tempting simplification — “always use ReqInfo.VersionID” — breaks copy. For a CopyObject, the source read is authorized as GetObject against the source’s version, which travels in the x-amz-copy-source header, and getConditionValues already extracts it there; ReqInfo.VersionID for a copy holds the destination query (usually empty). A blanket rebind would overwrite the correct copy-source version with the wrong one. Every non-delete version-aware operation (Get, Head, tagging, retention, copy-source read) carries its version in the query or the copy-source header, both of which the builder reads, and both of which are the effective version for a single object. Only Multi-Delete diverges. So the override is precisely as wide as the divergence, and no wider.
The version the server acts on is the trimmed one
One gap remained once the delete paths were correct. The builder read the version raw:
while every path that actually uses the version trims it first — newContext (cmd/utils.go:806) and getOpts (cmd/object-api-options.go:101) both strings.TrimSpace. So on non-delete version-aware actions, a padded ?versionId=V%20 presented "V " to the policy engine while the object layer read, tagged, or retained version "V". A Deny keyed on StringEquals s3:versionid "V" — “protect this exact version” — saw "V ", failed to match, and did not fire; the operation on "V" proceeded. A narrow bypass (the attacker must know the version and that a space changes nothing downstream), but a real one.
The fix trims both reads, aligning the condition value with the effective version:
DeleteObjectAction was already immune, because it uses the already-trimmed ReqInfo.VersionID. Trimming introduces no new allow: it can only make the condition value equal the version actually operated on, which tightens Deny and corrects Allow in the same direction. We proved it is load-bearing by removing only the trim and watching the padded test case go red.
What it affected
The reported break is on delete, but the underlying key is read by many actions. After the fix, every version-aware chain evaluates s3:versionid against the version the server resolves for that operation:
| Call chain | s3:versionid source |
Effective |
|---|---|---|
Single DeleteObject |
ReqInfo.VersionID = trimmed query, via override |
✓ |
DeleteObjects, per entry |
ReqInfo.VersionID = XML-body version, via override |
✓ — the fail-open closed |
GetObject / HeadObject / Select |
query, now trimmed | ✓ |
| Object tagging / retention / legal-hold | query, now trimmed | ✓ |
CopyObject / CopyObjectPart source read |
x-amz-copy-source version, now trimmed |
✓ |
| Anonymous 404-vs-403 probes | query (read-only) | ✓ |
| Admin / KMS / metrics / STS | no version concept | ✓ |
A forgery route was already closed by the earlier condition-source work and is worth restating: versionid is a reserved internal key (both the versionid and canonical Versionid spellings), so a client cannot inject a second copy through the header/query merge loops. The wire parameter is spelled versionId (capital I) and lands in an inert args["versionId"] the engine never reads.
Two directions of impact, kept distinct because they have different severities:
- Functional (the report): version-less deletes were wrongly denied. Fail-closed — an availability and usability defect, not a grant.
- Security (the trap and the trim): the naive fix would have granted deletes of protected versions on Multi-Delete (fail-open); and the untrimmed value permitted a narrow
Denybypass on read/tag/copy. The fix closes the first before it can exist and the second where it already did.
How we classify this
We are not minting a CVE for this, and the honest reasons are worth stating.
The behaviour the reporter filed is fail-closed: MinIO denied operations the policy meant to allow. A system that is too strict leaks nothing and grants nothing; it is a correctness and usability defect, and inflating a false-deny into a vulnerability would cheapen every real entry in this chronicle, whose neighbours are authentication bypasses and path traversals.
What carries genuine security weight is not the report but its vicinity. The fail-open on Multi-Delete is real, but it is a hazard we would have introduced, not one that shipped — the value of the two-part design is that the dangerous version never existed in a build. The trim bypass did exist, but it is narrow: it requires a Deny keyed on an exact s3:versionid, an attacker who knows the version, and it only ever affected non-delete paths. We closed it because it was in reach, not because it was a headline.
So: policy-enforcement correctness, filed here because that is where we keep silent enforcement failures, with the security interest recorded plainly rather than dressed up.
The boundaries we did not cross
Two same-class residuals remain, recorded rather than silently left:
- Governance-bypass in Multi-Delete. When an entry carries object-lock,
enforceRetentionBypassForDeletere-authorizes underBypassGovernanceRetentionAction(cmd/bucket-object-lock.go:153). That action is notDeleteObjectAction, so the effective-version override does not apply, and itss3:versionidis still the query value — absent in a normal Multi-Delete — rather than the per-entry version whose lock is being bypassed. - Snowball tar extraction.
PutObjectExtracttakes each member’s version from the tar PAX recordminio.versionIdafter the per-file authorization, so a named version can be written that never appeared in any condition value.
Both are narrow, both are pre-existing, and both would widen the change from “fix the reported key” into “re-plumb every action’s version into ReqInfo.” We scoped to the reported surface and wrote the IOUs down here, for the same reason the previous article recorded its object-layer omission: a deliberate omission that is not written down is indistinguishable from an oversight six months later.
A related decision, declined: the sibling keys username, userid, signatureversion, and authType are still written unconditionally empty, carrying exactly the present-but-empty defect versionid just shed — Null:{aws:username:true} is always false, including for the anonymous caller it should match. Fixing them is a one-liner each and a forty-caller blast radius, and some (principaltype is never empty) do not share the bug at all. We did not bundle a broad presence sweep into a versionid fix; it is named here as the next thread to pull.
Falsification
Three experiments, in the discipline that a test you have not watched fail is not yet a test.
- Revert both source files to
HEAD. The end-to-end DeleteObjects test turned red with every version-less entry returningAccessDenied— a faithful reproduction of issue #21735 — and the unit test caught the{""}key directly (“an absent versionId was exposed to policy evaluation”). Reapply, green. - Remove only the
TrimSpace. The padded case went red on the exact assertion —got [7f4b6b5f-…dd8 ]— proving the trim is not decoration. Restore, green. - The decoy. The Multi-Delete test appends
&versionId=query-level-decoyto the URL and asserts it reaches no entry’s decision, which is what distinguishes “reads the query” from “reads the effective per-entry version.”
The change touched only five files (cmd/bucket-policy.go, cmd/auth-handler.go, two tests, one doc example), committed with explicit paths in a working tree that had concurrent unrelated work in it, so nothing from the neighbouring efforts was swept in.
Source and lineage
The report is upstream minio/minio#21735, opened 2026-01-10 against RELEASE.2025-09-07T16-13-09Z: a Null:{s3:versionid:true} policy denying version-less DeleteObjects. The upstream repository went archived and read-only on 2026-04-25, so there is no upstream fix to wait for and no maintainer to coordinate with — the fork is the only venue, and the record here is the resolution.
The defect is old and inherited. getConditionValues has written versionid unconditionally for as long as the key has existed; the length-based Null semantics are upstream’s, in the policy package the fork consumes via silo-pkg. This is the same function and the same lineage as the earlier condition-source hardening that stopped client input from shadowing server-derived condition values — a related read of “what a policy condition is allowed to believe about a request,” continued here into “and it must believe the version the server will actually act on.”
Closing
Absent is not empty. A map that cannot say “no version” by leaving the key out will say it by leaving the value blank, and a Null that counts length will believe a version was named on every request that named none.
If one sentence survives: a fail-closed bug is the dangerous kind to fix, because the obvious repair flips it to fail-open — so bind the condition to the value the server actually acts on, from the same channel the operation reads, not the channel that was convenient; and when you stop at the reported surface, write down the versions you left on the wrong channel, rather than trusting the next person to find them.
3.14 - Three Headers, One Lie: Making the Client Source Address Mean Something
Status: Landed on pgsty/minio master as fe6dc4780, unreleased
Classification: Opt-in hardening plus a documentation defect, not a vulnerability and not a regression; no CVE assigned. The underlying weakness is inherited from upstream and its default behaviour is unchanged here
Affected scope: aws:SourceIp policy conditions, the audit log remotehost field, S3 event notification Host, and the client shown by mc admin trace — on any deployment whose S3 API port is reachable without passing through a header-sanitising proxy
Upstream: nothing to file — minio/minio is archived. Prior art there: PR #4736 (2017, the concern raised and half-addressed), discussion #17878 (2023, maintainer marks it working as intended), PR #20977 (2025, the partial switch)
This article states plainly that an
IpAddresspolicy condition is not enforceable on a directly-reachable MinIO deployment, and that this remains true by default after the change. That is a property of upstream MinIO as shipped, not a defect introduced by the fork, and it has never been documented anywhere. Publishing it is the point.
Conclusions first
- MinIO reads the client’s address out of three interchangeable headers —
X-Forwarded-For,X-Real-IP, RFC 7239Forwarded— and never from the TCP connection unless all three are absent. That address becomesaws:SourceIpand the audit log’s client field, so whoever controls it controls both IP-based access control and the attribution of every logged action. - The one switch that existed,
_MINIO_API_XFF_HEADER=off, suppresses one of those three. An attacker’s response is to sendX-Real-IPinstead. Our own code comment was recommending it as the mitigation. - “Put MinIO behind a reverse proxy” is not sufficient, for two independent reasons: on Kubernetes an Ingress and a ClusterIP Service routinely coexist so the proxy is not the only way in; and the stock nginx recipe appends to
X-Forwarded-For, leaving a client-supplied entry in the left-most position — which is exactly where MinIO reads. - The fix is a new opt-in setting,
MINIO_API_TRUSTED_PROXIES, generalising a trusted-proxy mechanism this fork already built for LDAP STS rate limiting. Set to a list, forwarded headers are believed only from those peers and chains are walked right-to-left. Set tonone, nothing is believed. - The most consequential decision was one we reversed. The first implementation widened
_MINIO_API_XFF_HEADER=offto suppress all three headers. That was the only part of the change that could alter an existing deployment’s behaviour, and it was backed out. The switch keeps its exact upstream semantics, and upstream’sTestXFFDisabledis retained unmodified as the proof. - Net compatibility impact: none for any deployment that does not opt in.
- Adversarial review found four defects in the first implementation, including one that made the new setting silently ineffective for every deployment configured through an environment file.
What the address is actually used for
The value comes from a single function, handlers.GetSourceIPFromHeaders. Tracing its consumers is what turns this from a logging curiosity into a security question:
| Consumer | Why it matters |
|---|---|
aws:SourceIp (cmd/bucket-policy.go) |
Decides IpAddress / NotIpAddress policy conditions |
Audit remotehost |
The record used to investigate every other incident |
Event notification Host |
Flows to downstream consumers as fact |
mc admin trace client |
Operator’s live view of who is doing what |
Two of these are security-relevant in different ways. A forged aws:SourceIp is a live access-control bypass: an IpAddress condition meant to confine a principal to an office CIDR is satisfied by asserting an address in that CIDR, and a NotIpAddress deny is evaded by asserting one outside it. A forged audit address is quieter and arguably worse — it corrupts the record retroactively, it applies even where no IP-based policy exists, and nobody notices until they need the logs.
Note also that the value is never validated as an IP address in the default path. Forwarded: for="_gazonk" is accepted and returned verbatim; upstream’s own test asserts it.
The setting that looked like a mitigation
internal/handlers/proxy.go gates exactly one header:
Setting _MINIO_API_XFF_HEADER=off costs an attacker one line: send X-Real-IP instead of X-Forwarded-For. Worse, disabling X-Forwarded-For moves the trust to a header the operator has not thought about, so the switch can leave a deployment in a state its owner has not modelled.
Where did it come from? Upstream PR minio/minio#20977, whose entire stated motivation is:
Customer request to disable all XFF header handling, ping me in Slack for more details.
No security rationale, no mention of X-Real-IP or Forwarded, no public discussion of why one header was gated and two were not. The narrowness is an oversight, not a considered scope. That mattered for the design, because it meant nobody had decided the other two should stay trusted — but as we will see, it did not end up justifying a change to the switch.
Upstream knew, in 2017
The most interesting thing found while writing this up is that none of it is news to upstream. The history is a small lesson in how a security decision decays.
August 2017. IpAddress / NotIpAddress condition support is added in PR #4736. During review the maintainer, @harshavardhana, raises exactly the concern this article is about: X-Forwarded-For is trivially spoofed, the left-most entry is the client’s own, and using it for a security decision would let a malicious client reach objects. The contributor accepts it and removes X-Forwarded-For support entirely, leaving only X-Real-IP, on the stated reasoning that a proxy sets it and a client cannot manipulate it. Merged five days later.
That reasoning is half right, and its unstated half is the whole problem: X-Real-IP is untamperable only if the proxy in front overwrites it. Nothing enforced that, and nothing told operators it was load-bearing.
Today. X-Forwarded-For is read first, ahead of X-Real-IP. The 2017 decision did not survive; it dissolved across later refactors of the condition-value plumbing rather than being reversed on purpose. There is no commit that says “we are re-admitting the spoofable header into policy decisions” — which is precisely how this class of decay happens.
August 2023. In discussion #17878 an operator reports that source IPs behind a load balancer are unreliable. The maintainer’s answer is unambiguous: without reliable source-IP visibility, IP-based restrictions are impractical, and the recommendation is to compartmentalise by tag or namespace instead. Marked working as intended.
So upstream’s own position — stated by a maintainer, in public — is do not rely on aws:SourceIp. That is a defensible engineering stance. What is missing is anywhere an operator would encounter it: it is not in the policy documentation, not in the condition-key reference, and not near the setting that appears to make it safe. An IpAddress condition is accepted without complaint and behaves as though it works.
That gap is the actual defect being fixed here, and it reframes the change. The allow-list is not overturning an upstream judgement; it is offering the mechanism that would make the 2017 concern answerable, to the deployments that want it. The documentation is doing the heavier lifting: writing down a contract that has been implicit since 2017 and contradicted by its own switch since 2025.
Why “put it behind a proxy” is not the answer
This is the standard advice, and it fails in two common, independent ways.
The proxy is not the only way in
On Kubernetes an Ingress and a ClusterIP Service routinely coexist. The Ingress sanitises headers; the Service does not, and any pod in the cluster can reach it. The security boundary is assumed to be the Ingress but is actually the pod network. The same shape appears in Pigsty deployments, where a load balancer fronts MinIO while the service ports remain reachable on the internal network.
The canonical nginx recipe preserves attacker input
The near-universal snippet is:
$proxy_add_x_forwarded_for expands to $http_x_forwarded_for, $remote_addr — it appends to whatever the client sent. A client sending X-Forwarded-For: 1.2.3.4 causes nginx to forward 1.2.3.4, <real client>. MinIO takes the left-most element, which is the attacker’s.
So a correctly-proxied, hardened, no-direct-access deployment is still forgeable, because left-most parsing and append-style proxies are mutually incompatible. Only proxy_set_header X-Forwarded-For $remote_addr; (overwrite) is safe under the default mode, and that is not what operators copy from the documentation.
This is the finding that rules out a documentation-only fix. Deployment discipline cannot close it; the chain has to be read from the other end, which requires code.
The design
Rather than inventing a mechanism, we generalised one this fork already has. MINIO_IDENTITY_LDAP_STS_TRUSTED_PROXIES — added during the LDAP STS throttling work — already implements CIDR allow-list parsing, catch-all rejection, and a right-to-left chain walk, scoped to rate-limit bucketing. The parser moved to internal/config and both paths now share it.
One setting selects the mode:
| Mode | MINIO_API_TRUSTED_PROXIES |
Source address |
|---|---|---|
| Untrusted (default) | unset | unchanged from today |
| Trust nobody | none |
always the TCP peer |
| Allow-listed | addresses and CIDR blocks | forwarded headers, only from listed peers |
Under the allow-listed mode, X-Forwarded-For and Forwarded are read right-to-left, stepping over entries that name a listed proxy, and the first remaining address wins. Each proxy appends the peer it actually saw, so an entry the client injected sits to the left of the one its proxy wrote and the walk stops before reaching it. Appending proxies become safe.
GetSourceScheme is deliberately untouched. It feeds the Location URL in S3 responses rather than a policy decision, and suppressing it would hand http:// URLs to every deployment terminating TLS at a proxy.
Trade-offs
Whether to change the default
Changing the default to distrust forwarded headers would make every existing reverse-proxy deployment’s aws:SourceIp and audit addresses become the proxy’s address overnight. Policies could fail closed; audit continuity would break.
Not changing it leaves directly-reachable deployments exposed.
Decision: do not change it. But “do not change” is not the same as “stay silent”. The consequence is now written into the code comment and the operator documentation in as many words: under the default mode, an IpAddress condition is not access control and the audit address is not evidence. Making the cost visible so operators can choose is better than making a choice for them that detonates during a release window.
Whether to widen the existing switch
This is the decision we got wrong first and reversed, and it is the part of the story most worth recording.
The original brief asked that when an operator explicitly disables forwarded-header trust, clients must not be able to forge through an equivalent header. The obvious reading is “fix _MINIO_API_XFF_HEADER=off so it covers all three”, and that is what the first implementation did.
The case for widening was decent. Upstream’s PR title says “disable all X-Forwarded-For header handling”; its described scope is audit logs and IP-based access control, both of which are about not believing client-claimed addresses; the switch is undocumented, so its audience is small; and the failure direction is safe, since the fallback is the real TCP peer rather than an attacker-controlled value.
The case against turned out to be decisive. Widening it changes behaviour for a real population: operators whose proxy appends to X-Forwarded-For (polluted) but overwrites X-Real-IP (clean) may have discovered off as a way to get the correct address. Upstream’s TestXFFDisabled asserts exactly that behaviour — with the switch off and both headers present, X-Real-IP wins — so the behaviour is not merely incidental, it is pinned by a test. Those operators would have seen audit addresses silently flip from the real client to their proxy, and IpAddress conditions potentially start denying.
The reversal came from separating the goal from the mechanism. The goal was “a complete, enforceable way to turn this off exists”. Nothing required that the existing variable be the thing that provides it. Expressing it as MINIO_API_TRUSTED_PROXIES=none achieves the identical guarantee with zero effect on anyone who has not opted in.
Decision: leave _MINIO_API_XFF_HEADER exactly as upstream defined it. It gates X-Forwarded-For only, within whichever trust mode is in force. Upstream’s TestXFFDisabled is retained unmodified and still passes. The documentation now says plainly what the switch is not: it is a parsing switch, not a trust boundary, and a client refused one header simply sends another.
A secondary benefit: this collapses two interacting variables into one policy setting, so there is no longer a precedence rule ("off outranks the allow-list") for operators to learn and for us to get wrong.
Environment variable or config subsystem
Registering trusted_proxies as an api subsystem key would give mc admin config visibility, help text, and hot reload, matching how sts_trusted_proxies is done.
It would also introduce a window. Config subsystems load after the object layer initialises, so between process start and config application the trust policy would be empty — which under a “list is empty means trust everyone” reading is fail-open. A security boundary must not have a fail-open window. Separately, a trust boundary that can be changed at runtime is not obviously desirable.
Decision: environment variable. The cost is discoverability, and one bug described below.
Loopback is always trusted as a peer
The FTP and SFTP front-ends connect to the S3 layer over 127.0.0.1 and declare their session’s client with X-Forwarded-For (cmd/sftp-server-driver.go). An allow-list that does not exempt loopback attributes every FTP and SFTP request to the server itself.
The cost is that any process on the same host can forge. That is acceptable: an attacker who can open connections from localhost already has code execution on the host, and the threat model is lost well before this point. The FTP/SFTP regression, by contrast, would be certain and would affect everyone using those front-ends.
Decision: exempt loopback as a peer. Adversarial review then caught that the first implementation also treated loopback as a skippable chain entry, which is unnecessary for the FTP/SFTP case and actively harmful — see below. The two are now separate checks.
X-Forwarded-For versus X-Real-IP: genuinely unresolvable
Under the allow-listed mode, which header wins when both are present?
- A proxy that authors only
X-Real-IPand relays the client’sX-Forwarded-For(some nginx configurations) → preferringX-Forwarded-Fortakes the forged value. - A proxy that authors only
X-Forwarded-Forand relays the client’sX-Real-IP(AWS ALB) → preferringX-Real-IPtakes the forged value.
Both are common, and the server cannot tell which situation it is in from the request. This is not an undecided question; it is undecidable without the operator telling us what their proxy authors.
Decision: prefer X-Forwarded-For. It is the only one of the two that carries a chain that can be checked against the allow-list, and this path decides access control rather than rate-limit bucketing, so the value that can be validated should win. It also keeps header precedence identical to the default mode, so switching modes does not silently change precedence as well.
This deliberately diverges from getSTSLDAPTrustedProxySourceIP, which prefers X-Real-IP. Two contradicting implementations of the same question in one codebase is a hazard in itself, so both sites now carry a comment naming the divergence and its reason, so that nobody “unifies” them without re-deciding. The operator documentation states the mitigation for both directions: strip whichever header your proxy does not author.
A broad allow-list is worse than no allow-list
This is the most counter-intuitive property and the one most likely to bite.
Entries on the list are skipped during the chain walk. So MINIO_API_TRUSTED_PROXIES=10.0.0.0/8, configured because the load balancer is 10.0.0.1, makes every client inside 10/8 skippable as well. A client at 10.5.5.5 sending X-Forwarded-For: 8.8.8.8 produces a chain of 8.8.8.8, 10.5.5.5; the walk steps over 10.5.5.5 as a “trusted hop” and returns 8.8.8.8.
A broad list therefore does not merely trust more peers — it lets those peers forge. nginx’s set_real_ip_from has the same property with real_ip_recursive.
There is no algorithmic fix: the list is doing double duty as “who may forward” and “whose address may be discarded”, and separating them would mean two lists to keep in sync. Decision: keep one list, and make the constraint prominent — a callout block in the operator documentation, and the rule stated in the code comment where the walk happens. Only /0 is rejected as a catch-all, and the documentation says explicitly that this is a guardrail rather than a proof, since 0.0.0.0/1,128.0.0.0/1 covers the same ground.
Multi-node forwarding
MinIO forwards requests between nodes for bucket-DNS routing, listing continuation, heal-by-token, batch jobs and pool decommissioning. The receiving node’s TCP peer is the forwarding node, not the client.
| Mode | Receiving node resolves |
|---|---|
| Default | the client |
none |
the forwarding node |
| Allow-list without node addresses | the forwarding node |
| Allow-list with node addresses | the client |
This is not an obscure path: a ListObjectsV2 continuation token carries the node index, so any client can cause its own request to be forwarded. Under none, that request is then evaluated with aws:SourceIp set to an internal node address — which an IpAddress condition allowing internal ranges would treat as a pass.
Decision: document it, and steer multi-node clusters to the allow-list. none cannot be corrected for this case, because it believes nothing by definition. Automatically seeding the cluster’s own addresses was considered and rejected: it needs DNS resolution at startup and re-resolution as node addresses change, which is more machinery and more failure modes than the explicit configuration it replaces.
Compatibility
The change was deliberately structured so that risk is not spread evenly across it. Every piece is either opt-in or dead code in the default configuration.
| Change | Who is affected | Risk |
|---|---|---|
MINIO_API_TRUSTED_PROXIES allow-list |
only those who set it | none |
Forwarder sanitises X-Real-IP / Forwarded |
code path does not execute in default mode | none |
| Startup failure on a malformed value | only those who set it, incorrectly | none |
| Policy re-read after environment-file load | same result when nothing is set | none |
| LDAP parser extracted for sharing | nobody — pure code motion, verified identical | none |
_MINIO_API_XFF_HEADER semantics |
nobody — reverted | none |
_MINIO_API_XFF_HEADER read timing |
nobody — upstream timing kept deliberately | none |
Evidence for the default path. unverifiedSourceIP is a verbatim copy of the original function body, including its quirks: the ", " separator, the fall-through when the left-most element is empty, and the acceptance of non-IP values such as _gazonk. An independent review verified behavioural parity against HEAD over 21 cases — empty X-Forwarded-For, a bare comma, a leading ", ", "," versus ", " separators, " , ", IPv4-mapped addresses, bracketed IPv6, non-IP junk, and all three Forwarded forms. Upstream’s TestGetSourceIP and TestXFFDisabled are both retained unmodified and pass.
A subtlety self-review caught. The new setting is read after MINIO_CONFIG_ENV_FILE is loaded, which is what makes it work in packaged deployments. The obvious tidiness move is to read _MINIO_API_XFF_HEADER in the same place — and that would have been a behaviour change, because upstream reads it at package initialisation, before environment files exist. An operator who wrote it into an environment file has it silently ignored today; picking it up would make an already-deployed setting suddenly start working, flipping their source addresses from the left-most X-Forwarded-For entry to X-Real-IP. The old switch therefore keeps upstream’s read timing along with upstream’s semantics, and a test pins that so nobody tidies it later. The quirk is documented instead: set it in the process environment if you want it honoured.
The defensive code that was not defending anything. Sharing the parser initially came with two extras: allow-list entries written in IPv4-mapped form were rewritten to the IPv4 prefix they denote, and the address being matched was unmapped and de-zoned. Both looked like corrections — an entry written ::ffff:192.168.1.10 is otherwise accepted and then matches nothing, which is a silent failure worth removing.
They were removed anyway, and the reason is worth recording. Both call paths reduce the address through net.ParseIP(...).String() before matching, and that already collapses ::ffff:10.0.0.1 to 10.0.0.1; a dual-stack listener reports an IPv4 peer in plain form regardless. So neither extra could be reached by a real request. Their only observable effect was on what the shared function meant for the LDAP STS allow-list that had been using it first — 18 differences that a test could see by calling the function directly and no deployment could.
Worse, one of them manufactured the fail-open described below: rewriting ::ffff:0:0/96 turned a /96 into 0.0.0.0/0. Deleting the rewrite removes the bug’s cause rather than ordering around it. What remains is pure code motion, verified identical to the previous implementation across every combination of 37 allow-list values and 21 peer addresses — zero parse differences, zero match differences. The wart it declined to fix (a mapped-form entry matches nothing) is the pre-existing behaviour, fails closed, and is now stated in the function’s own comment so the next person does not re-derive the same tempting fix.
The one residual risk worth naming. The default mode’s code path did change: there is now a switch and a function call in front of the original body. If that plumbing were wrong it would affect everyone, not just opt-in users. The parity testing above is why we believe it is not, but “verified equivalent over 21 cases” is a different claim from “provably identical”, and the honest version is the former.
What adversarial review found
An independent agent was tasked with breaking the first implementation. It found four real defects, all since fixed and covered by regression tests.
A silent fail-open, and the worst of the four. The trust policy was read in the package’s init(). But loadEnvVarsFromFiles() calls os.Setenv for everything in MINIO_CONFIG_ENV_FILE long afterwards — which is how MinIO is configured in essentially every packaged deployment. An operator putting MINIO_API_TRUSTED_PROXIES in /etc/default/minio would have got the historical trust-any-peer mode, with no error reported, and a malformed value would have been silently ignored rather than fatal. The policy is now applied in serverHandleEnvVars, which runs after the file load and before any listener.
Loopback skipped as a chain entry. Described above: the peer exemption was being reused as a hop exemption, which is a needless instance of the broad-list problem. Now two separate checks.
Two fail-closed correctness bugs. A zoned IPv6 peer (fe80::1%eth0) canonicalised to nothing, because net.ParseIP rejects zones — so such a peer could never be a trusted proxy. And an allow-list entry written in IPv4-mapped form (::ffff:192.168.1.10) was accepted at startup and then matched nothing at all, since netip.Prefix.Contains is false across differing bit widths.
Two further defects were found while writing the documentation rather than the code, which is its own small lesson:
Repeated header lines. Header.Get returns only the first header line. HAProxy’s option forwardfor adds a second X-Forwarded-For line rather than extending the first, so Get would hand back the client’s line and put the forged value right back where the right-to-left walk exists to avoid it. Now flattened across all lines with Header.Values.
The internal forwarder relayed client claims. internal/handlers/forwarder.go set X-Real-IP only when absent, so a client’s value passed between nodes unchanged. Under an allow-list that includes the cluster’s own nodes — the configuration we recommend — a client could thereby borrow a peer node’s authority. The forwarder now drops X-Real-IP and Forwarded when the incoming peer is not entitled to have set them. X-Forwarded-For needs no such handling, because Go’s ReverseProxy appends the true peer and the receiving node’s walk reaches that entry first.
A second round, after the rework
Reworking the setting warranted a second adversarial pass, which was worth running: differential testing found zero behavioural differences against HEAD across 4,745,520 source-IP resolutions and 345,600 forwarder rewrites, but it also found three more ways to fail open — one of them introduced by the first round’s own fix.
A catch-all smuggled in as an IPv4-mapped prefix. MINIO_API_TRUSTED_PROXIES=::ffff:0:0/96 is a /96 as written, so it passed the catch-all check; the rewrite that unmapped IPv4-mapped entries then turned it into 0.0.0.0/0, trusting every peer. The first fix moved the breadth check to the far side of the rewrite. The eventual fix deleted the rewrite, once it became clear it was unreachable by any real request — which removes the cause instead of guarding its output.
This is the one most worth dwelling on. The fail-open was manufactured by a fix for an unrelated fail-closed bug, and the fix for the fail-open was a reordering that left the manufacturing step in place. Two rounds of correction, both defensible, neither addressing the fact that the code should not have been there. Hardening changes deserve the same adversarial treatment as the code they harden, and “is this reachable at all?” belongs near the front of that treatment.
A deliberate value naming nobody. MINIO_API_TRUSTED_PROXIES="," parsed to an empty list and fell back to the permissive default. An empty unset variable must mean “default”, but a value the operator actually typed which names no proxy is a mistake, and answering it with trust-everyone is the one behaviour they cannot have wanted. It is now a startup error. Whitespace-only remains equivalent to unset, since that is what an empty shell variable expands to.
A remote value that could not be read. MinIO supports env:// indirection, where a variable’s value is fetched from a remote webhook. env.Get discards the error from that fetch and returns the empty string — which this code would have read as “unset”, reinstating trust-any-peer at exactly the moment the operator’s intent could not be determined. The setting is now read through env.LookupEnv so the error is surfaced and startup stops. This is a general hazard for any security-relevant setting read via env.Get, and worth remembering beyond this change.
A third pass, attacking from angles the first two shared
Both earlier passes attacked the resolver as a unit. Two things that neither could see:
Nothing had tested that the trust policy reaches a decision. Every test to that point checked what the resolver returned, and the one test at the policy layer only asserted that aws:SourceIp equalled the resolver’s output — in the default mode. So a version where the resolver was correct but the policy engine read something else would have passed everything. There is now a test that drives a forged X-Forwarded-For through getConditionValues into a real IpAddress evaluation under each mode: believed by default, ignored under none, ignored from an unlisted peer, and still honoured from the listed proxy. It passes, but it should have existed before the change was called done.
The allow-listed mode had a resource amplification the default mode does not. The chain was flattened into a slice before being walked, so a client behind a trusted proxy could turn the 1 MiB header allowance into roughly 33 MB of slice headers per request — around thirty-fold — plus a million-iteration walk. The default path never had this, because it uses strings.Index on the raw header. The walk now scans backwards over the header text in place, allocating nothing, and stops after 100 hops; real chains are a handful, the answer sits at the right-hand end, and running out of budget yields no address, which falls back to the peer. A test pins the zero-allocation property, because it is the kind of thing an innocent-looking refactor would undo.
Also corrected in this pass: the deployment contract was stated too narrowly. “The proxy must overwrite whichever headers it sets” misses the case that actually bites — a proxy that correctly authors only X-Real-IP or only Forwarded still relays the client’s X-Forwarded-For, and that is the header read first. The general rule, now stated as such, is to strip every source-address header the proxy does not itself write.
One reported finding was reviewed and not treated as a defect: the catch-all guard rejects /0 and nothing else, so 0.0.0.0/1,128.0.0.0/1 covers the same ground and is accepted. Tightening it would mean rejecting broad-but-not-/0 prefixes in the parser now shared with the LDAP allow-list, newly failing configurations that are valid today, to defend against a value no deployment realistically holds. The documentation states plainly that the check is a guardrail rather than a proof, and the callout about naming proxies instead of subnets is where the real defence lives.
Recommendations
By topology:
- Proxy you control, API port genuinely unreachable otherwise. Set nothing. But verify whether your proxy overwrites or appends: if the config says
$proxy_add_x_forwarded_for, you are forgeable today. Switch to$remote_addr, or adopt the allow-list. - Direct exposure, no proxy.
MINIO_API_TRUSTED_PROXIES=none. - Kubernetes or Pigsty, Ingress plus reachable Service. The allow-list, containing the proxy addresses and the MinIO node addresses. This is the configuration that makes an
IpAddresscondition mean anything. - Any multi-node cluster. The allow-list with node addresses, not
none.
Two rules apply to every allow-list deployment. Name proxies, not the subnet they occupy. And strip at the edge every source-address header your proxy does not itself write — listing a peer means believing all three headers from it, they are consulted in a fixed order, and a header your proxy leaves alone is entirely the client’s. A proxy that correctly authors only X-Real-IP, or only Forwarded, still relays the client’s X-Forwarded-For, which is read first.
What was not done
- Automatic seeding of cluster node addresses. Feasible via
EndpointServerPools, but it needs DNS resolution and re-resolution on address changes. Explicit configuration was judged the smaller risk. - Registration as an
apiconfig key. See the fail-open window above. Revisitable if observability turns out to matter more than the startup guarantee. ExistingObjectTag/*. The sibling defect from the condition-value hardening — it carries the request’s own tags rather than the object’s stored tags — remains open by decision, and is unaffected by any of this.
Verdict on severity
Default behaviour matches upstream, and MinIO has never documented aws:SourceIp as trustworthy on a directly-reachable deployment, so “the default is unsafe” is closer to a documentation defect than a vulnerability. One thing is squarely a defect, though: an operator set an explicit security switch and it did not do what its name and its only public description implied, and it failed silently. That is worth a record, scoped to the incompleteness of upstream’s _MINIO_API_XFF_HEADER rather than to anything the fork introduced.
minio/minio is archived, so there is no upstream to coordinate with — the same position as CVE-2026-42600.
3.15 - Sorted Is Not Increasing: How One Duplicate Part Number Doubled an Object
Status: Fixed on the local pgsty/minio branch as 22c1e41fd, unreleased
Classification: Data correctness, not a vulnerability — see Why this is not a CVE
Affected scope: All backends, any authenticated S3 client, on its own upload
Tracking: pgsty/minio issue #49
One section of this article describes an unfixed process-level panic in a neighbouring code path. Hold publication until that is fixed and released.
Conclusions first
sort.SliceIsSortedwith a<predicate does not test strict increase. It tests the absence of an inversion. Equal neighbours contain no inversion, so[1,1]was accepted.- Upload one 5 MiB part, complete with
[1,1], and the server returns HTTP 200 and a 10 MiB object. The upload is then consumed: a corrected retry getsNoSuchUpload. The client cannot recover. - Inherited from upstream, and old. The check has had this shape since 2016-08. Two refactors — 2017 and 2023 — rewrote it faithfully, because each preserved the predicate, and the predicate was never the problem.
- The fix is one loop at the handler layer. The object layer is left undefended by decision, and that IOU is written down here rather than left implicit.
- Three independent reviews found no defect in the fix. What they found was a comment that misstated why a neighbouring guard exists — and, through that comment, an unrelated node-level panic.
The verb, not the predicate
The code, as inherited:
It reads as “reject unless the part numbers strictly increase.” It does not do that. IsSorted evaluates the predicate in the reversed direction only — for each neighbouring pair it asks less(i, i-1), i.e. “is this element smaller than the one before it,” and reports unsorted the moment one such inversion appears. For a pair of equal elements that question is false. No inversion, therefore sorted.
The consequence is worth stating precisely, because it is what makes the misuse survive review: no strict predicate can make IsSorted reject duplicates. The only spelling that works is the non-strict one — passing <= as the less function, so that equal neighbours register as an inversion. To ask for strictly increasing you would have to write the operator that reads as not strict. Every reviewer who checked that the predicate said < was checking the right character in the wrong function.
Our replacement drops IsSorted rather than trying to spell it correctly:
The rejection-set delta is exactly one class: lists containing an adjacent equal pair. Everything previously rejected is still rejected; everything previously accepted, except duplicates, is still accepted. Non-adjacent duplicates come along for free — a strictly increasing sequence is globally distinct, so [1,2,1] and [1,3,2,3] are caught by the inversion they are forced to contain.
Two refactors preserved it faithfully
The archaeology is the most transferable part of this incident.
| When | Shape | What changed |
|---|---|---|
| 2016-08 | sort.IsSorted(CompletedParts(parts)) |
already present at server: Move all the top level files into cmd folder (#2490) |
| 2017-11 | same call, Less moved onto an exported type |
Add public data-types for easier external loading (#5170) |
| 2023-04 | sort.SliceIsSorted(parts, func(i,j) bool { … < … }) |
simplify sort.Sort by using sort.Slice (#17066) |
Both refactors were correct as refactors: they preserved behaviour exactly, which is what a refactor is supposed to do. The 2023 commit was a repository-wide cleanup with no bearing on multipart semantics at all. It carried the < across unchanged, and the < was never wrong — CompletedParts.Less needs < to be a valid sort.Interface.
The defect lived in the relationship between the predicate and the function it was handed to, and a refactor that moves the predicate cannot see that relationship. A decade, three shapes, one behaviour: an ordering check that answers a question adjacent to the one it appears to answer.
What it actually did
Measured against both erasure backends, through the real signed HTTP handler:
| Uploaded | Completion list | Response | Resulting object | ETag suffix |
|---|---|---|---|---|
| one 5 MiB part | [1,1] |
200 OK | 10,485,760 bytes | -2 |
| two 5 MiB parts | [1,2,2] |
200 OK | 15,728,640 bytes | -3 |
| one 5 MiB part at 10000 | [10000,10000] |
200 OK | 10,485,760 bytes | -2 |
The ETag suffix is the part count the server believes it assembled. There is no internal disagreement to detect: the metadata, the size, and the ETag are all mutually consistent and all wrong. The object simply is not what was uploaded.
Two properties make this worse than a bad error code.
The upload is consumed. Assembly runs to completion and cleans up the multipart upload, so the corrected retry returns NoSuchUpload. A client that notices the wrong size cannot fix it by resending the right list; it has to start the whole upload over, if it still has the data.
It is reachable by accident. No adversary is required. Any client that appends a part to its completion list twice — a plausible bug in a resumable-upload wrapper, a retry path, or a list built by concatenation — silently gets a doubled part instead of a 400.
Why this is not a CVE
It belongs in this chronicle because it is a silent server-side correctness failure, and this is where we keep those. It is not a vulnerability, and we are not going to inflate it into one.
The request must carry the caller’s own credentials, address the caller’s own upload, and the damaged object is the caller’s own. There is no cross-tenant effect, no privilege change, no disclosure, and no path to another account’s data. What breaks is the guarantee that a completed multipart object equals the bytes you uploaded — serious, but a correctness guarantee, not an access-control boundary.
The entries around this one in this chronicle are authentication bypasses and path traversals. Filing this beside them under the same label would make every label in the table mean less.
The boundary decision, and what it costs
The object layer has no duplicate defence at all. erasureObjects.CompleteMultipartUpload sizes its output slice to the request (cmd/erasure-multipart.go:1249) and then resolves each requested part number against current metadata (:1255). The same number resolves twice, writes two identical ObjectPartInfo entries, and adds its size twice. AddObjectPart does deduplicate by part number, but it deduplicates the metadata slice, not the request. The 5 MiB minimum-size rule cannot help either, because the duplicated part is individually legal.
We fixed the handler and left that alone. The reasoning:
- It is the only entrance where a client-controlled list exists. The other four callers — batch, restore, decommission, rebalance — build their lists server-side from
oi.Partsor1..n, and are strictly increasing by construction. - The required output is an S3 error code, which is an API-layer concern. The object layer’s error vocabulary maps to a different code, so intercepting lower would hand clients a less accurate diagnosis.
- Minimality. This fork ships narrow fixes, and a change in the assembly loop is not narrow.
The cost, recorded rather than implied: the uniqueness invariant now has exactly one enforcement point, and nothing enforces the enforcement. No compiler error and no test failure will greet the person who adds a fifth caller to the object layer; they will get a silently corrupted object. That is the same species of IOU the previous article recorded about getVolDir, and it is written down for the same reason: an unrecorded deliberate omission is indistinguishable from an oversight six months later.
What we deliberately did not add
Part numbers need not start at 1 and need not be consecutive. [1,3], [5,9] and [3] are all legal S3, and all still complete successfully.
This matters more than it sounds. “Also require the list to start at part 1” is a one-line addition that looks like tightening, would pass a casual review, and would break legal clients — anything that abandons a part after a failed upload and completes with what it has. The temptation is real precisely because the fix next door is about validating the same list.
So two test cases exist for no purpose other than to make that change fail. We verified they do their job by injecting the constraint and confirming that exactly those two cases went red and nothing else did. A guard rail nobody has fired once is a guess.
The one behaviour change we did not intend
A 14-input differential against the pre-fix build turned up exactly one behavioural change beyond duplicate rejection: [0,0] and [-1,-1] — lists that are both duplicated and out of range — moved from InvalidPart to InvalidPartOrder. Both are HTTP 400.
We accepted it, on the principle that a format error should outrank a state error: an ordering violation is decidable without reading any storage, while part existence is not. It also only affects requests that were going to fail regardless, so no client that previously succeeded can now fail.
On S3 fidelity itself we are making a documented inference, not a measurement. AWS defines InvalidPartOrder as the parts list not being in ascending order, and documents that part numbers may be non-consecutive; duplicates are not ascending. We did not verify this against a live AWS endpoint, and two independent reviewers reached the same conclusion by the same documentary route, which is agreement, not evidence.
Falsification, and a comment that was wrong
Two mutation experiments, in the discipline the previous article argued for — a test you have never watched fail is not yet a test.
Inject “must start at part 1.” Exactly the two gap cases went red; the four lists starting at 1 stayed green. The guard rail is targeted, not incidental.
Delete the neighbouring len(Parts) == 0 guard. The expected result was that an empty completion would produce some wrong-but-orderly error. The actual result was that the process panicked: the empty list reaches a storage decorator that indexes element zero of the part-path slice without a length check, on a goroutine that no recover can reach. The S3 face is masked by that one guard line, which has been there since 2022 and is not documented as load-bearing. It is tracked separately as an unfixed node-level defect, which is why this article is held.
And the part worth publishing at our own expense: the comment we wrote about that guard was wrong. It said dropping the length check would let an empty completion succeed — the opposite direction of the truth, and specifically the direction that understates danger. It was caught in review and corrected before the commit. A comment that misstates why a check exists is exactly how the check gets deleted three years later by someone tidying up.
Three acceptances, zero blocking findings
The change went through three independent gates before commit:
| Gate | Method | Outcome |
|---|---|---|
| Author | revert the fix, watch the test go red at the measured 10 MiB, reapply, watch it go green | red/green established |
| Independent reviewer | rebuilt the red state in its own detached worktree rather than trusting the report; 14-input differential | no blocking finding |
| External model, different vendor | read-only sandbox, independent derivation of the rejection-set argument and of the AWS reading | conditional accept; the condition was that it could not compile in its own sandbox |
Stated plainly, because the honest version is less flattering than the table: none of the three found a defect in the fix. What review produced was the corrected comment and, through the mutation it prompted, the discovery of the unrelated panic. That is still a good return, but it is not the same as catching a bug in the patch, and the record should say which one happened.
The rebuilt-red-state detail is the one worth copying. A reviewer who reruns the author’s tests is checking the author’s arithmetic; a reviewer who reconstructs the broken state independently is checking the author’s claim.
Declined, and left open
Declined, deliberately:
- Two test additions — completing onto a pre-existing object, and giving each part distinct content so ordering is verified rather than just total size. Both are real improvements. Both were declined under a standing rule that this fork ships correctness and security fixes rather than test expansion, and the core invariant is already pinned by “the rejected request left no object and the upload still works.”
- XML root element name is not validated. A document with the wrong root but correct
<Part>children is accepted. This is not a bypass — the same list still goes through the same check — it is pre-existing, and tightening it risks breaking real SDKs over namespace handling. Recorded, not fixed.
Left open, none of it in a released build as of 2026-08-03:
- Object-layer defence in depth for part uniqueness (see above).
- The empty-list panic in the storage decorator, tracked as a node-level defect.
- XML strictness, including
<PartNumber>abc</PartNumber>returning 500 where 400MalformedXMLis correct.
The concurrent checksum work on completion (#46, #48, #50) was fenced off from this change entirely and shares no code with it.
Closing
The predicate was strict. The verb was not. A decade of review read the predicate — including the two commits that rewrote the line.
If only one sentence survives: check what the function does with the comparison, not just what the comparison says, and when you decide to leave the layer underneath undefended, write it down where the next person will trip over it, rather than trusting that they will re-derive your reasoning.
4 - Design Records
Design records capture the reasoning behind SILO maintenance decisions: the problem being solved, the compatibility boundary, rejected alternatives, implementation requirements, and the evidence required before release.
4.1 - Conditional DELETE: Why the Condition Must Be Evaluated Once
This document records the analysis, design discussion, and repair decision for SILO PR #12.
Status on 2026-08-26: PR #12 remains open at head
5b71a75e, 118 commits behind the latestmain. Its commit has no DCO sign-off and GitHub reports no check runs. The improved design described here has been implemented, tested, and reviewed twice in an isolated local worktree, but it has not been committed, pushed, merged, or released.
Scope: correctly supportIf-Matchfor the single-objectDeleteObjectAPI, and fail closed instead of silently deleting when an unsupported per-objectDeleteObjectsETag is received. Full batch-condition execution and bucket-policy enforcement remain separate deliverables.
Release boundary: local implementation, tests, review, commit, push, remote CI, merge, tag, image publication, and production deployment are independent gates.
Too Long; Didn’t Read (TL;DR)
The underlying problem is real. SILO currently ignores If-Match on DELETE, so a client can believe it is performing compare-and-delete while the server performs an unconditional deletion. PR #12 targets the right problem and correctly recognizes that the condition must be evaluated against fresh object state while holding a lock.
The original implementation puts the same HTTP callback into every erasure pool. Different pools can retain copies from different points in time, so each pool evaluates and mutates against its own ETag. A two-pool test reproduced both failures:
- the request ultimately returns 412 after the older matching copy has already been deleted;
- the request succeeds after deleting the current copy, while an older non-matching copy remains and becomes visible again.
The selected repair introduces no new condition framework. It follows the established multi-pool GET pattern: select the current object under the outer namespace lock, evaluate the condition exactly once, then clear the callback before calling lower pools. A false condition mutates no pool. A true condition allows the existing cleanup to run without reinterpreting the client condition per copy.
Why this is a real problem
Silently dropping the condition is unsafe
AWS conditional-delete documentation now defines the behavior for general-purpose buckets and both DeleteObject and DeleteObjects:
| Request | Meaning | Result and permission |
|---|---|---|
If-Match: <ETag> |
Delete only if the current object is still the state observed by the caller | 204 on match, 412 otherwise; requires s3:GetObject and s3:DeleteObject |
If-Match: * |
Delete only if a current object exists | 204 when it exists; requires only s3:DeleteObject |
| Missing key | No condition can be satisfied | Not Found |
| Current delete marker | No current object exists | If-Match: * returns 412 |
Ignoring the header is therefore not a harmless unsupported extension. It removes the concurrency guard the caller used to avoid deleting another writer’s newer object.
Not every S3 client sends conditional deletes, so prevalence is unknown. Severity for each relying caller is high: one silent downgrade can remove newly committed data.
A delete marker is not merely an ETag comparison case
PR #12 reuses the generic isETagEqual, which returns true whenever the right-hand value is *. Consequently, isETagEqual("", "*") is also true.
The more fundamental bypass occurs one layer above. erasureServerPools.DeleteObject returns success immediately when the current object is already a delete marker. That happens before the callback added by the PR. A diagnostic test observed zero callback calls and a successful result.
The impact needs precise wording:
- the delete-marker fast path did not remove a historical version or create another marker in the reproduced case; it bypassed the condition and falsely reported success;
- the multi-pool counterexamples do mutate storage on a failed request or leave a stale copy after success.
Changing only isETagEqual("", "*") cannot cross the outer fast path and risks altering a comparator shared by GET, PUT, and COPY.
What the original PR got right
Its high-level algorithm is sound:
- detect
If-Matchin the handler; - read fresh
ObjectInfoafter acquiring the storage lock; - return before mutation when the condition is false;
- encode the result as an S3 response.
This avoids the obvious TOCTOU window of a separate HEAD followed by DELETE. The PR also adds handler, helper, and erasure-layer tests. Its ordinary single-pool path correctly returns 412 and preserves the object for a wrong specific ETag.
The defect is not the decision to evaluate under a lock. It is choosing the wrong layer and therefore the wrong object state.
Where the atomicity boundary lives
The deletion path has two layers:
Only erasureServerPools.DeleteObject knows:
- which copy represents the current object;
- which pools still contain older copies or inconsistent metadata;
- whether the delete-marker fast path applies;
- whether multiple pools will be mutated concurrently.
The client condition therefore belongs at this layer. A single pool knows only its local copy and cannot reinterpret a condition on the logical current object.
Two-pool counterexamples
The test places an older object in pool 0 and a newer object with a different ETag in pool 1. Reads select pool 1 as current, while an unversioned delete cleans both pools.
Condition matches the old copy
The original PR lets pool 0 pass and delete its copy while pool 1 fails. The aggregate result follows the current pool and returns 412, even though storage changed.
Condition matches the current copy
Pool 1 passes and deletes the current copy. Pool 0 fails and retains the old copy. The request returns success, after which the old object becomes visible again.
The callback also captures a single http.ResponseWriter. Calling it concurrently from multiple pools can make multiple goroutines write the same HTTP response. Storage replicas should not concurrently decide wire-level output.
A pre-existing degraded-pool limitation
There is one related but inherited limitation outside this patch. If the selected current pool is readable and writable but an older, non-current pool is degraded, the existing all-pool delete path can return the selected pool’s success while an error from the older pool is not surfaced. That copy can remain and reappear after recovery.
The new condition does not create this behavior: it evaluates the readable current object correctly and then enters the same unversioned multi-pool cleanup used by an unconditional delete. Repairing error aggregation and recovery for partially degraded old pools should be tracked separately because it changes the guarantees of every unversioned multi-pool delete, not only conditional requests.
The selected minimal repair
1. Evaluate exactly once at the outer layer
After erasureServerPools.DeleteObject acquires the namespace write lock:
- save
opts.CheckPrecondFn; - remove it from options passed to lower layers;
- inspect all pools and select the current
pinfo; - if the current object cannot be read reliably, return a quorum error without calling the callback;
- call the saved callback exactly once with
pinfo.ObjInfo; - on success, continue through the existing deletion path with no lower-layer reinterpretation.
This pattern already exists in multi-pool GetObjectNInfo: save the callback, clear it below, select the latest object, and evaluate once. Reusing it limits the DELETE change to the real atomicity boundary.
2. Treat * as current-representation existence
The DELETE-specific check separates wildcard and ETag semantics:
A missing key already returns Not Found during object selection. A current delete marker reaches the callback and returns 412. The generic isETagEqual remains unchanged.
3. Require read permission for a specific ETag
The handler first checks s3:DeleteObject. When the normalized condition is not a bare *, it additionally checks s3:GetObject:
- delete-only policy plus
*: allowed; - delete-only policy plus a specific ETag: 403 and no mutation;
- Get plus Delete and a matching ETag: allowed.
Authorization completes before any storage mutation.
4. Do not require SSE-C content decryption for DELETE
The original PR invokes the GET/PUT-oriented DecryptObjectInfo, which rejects an SSE-C object when SSE-C read headers are absent. Conditional DELETE needs the client-visible ETag, not plaintext content or decrypted size.
The selected implementation uses the established getDecryptedETag projection only for a specific ETag. Wildcard requests do not read the ETag. This reuses existing ETag behavior without imposing content-decryption requirements on DELETE.
5. Evaluate the current version
AWS specifies that conditional-delete evaluation applies to the current version. SILO’s outer pool selection already reads the current object, while preserving an explicit versionId for the eventual version deletion.
A regression test requests deletion of a historical version while matching that historical ETag rather than the current ETag. It must return 412 and preserve both versions.
6. Reject silent downgrades at unsupported edges
Two small guards keep the single-object feature from being bypassed:
- an empty or whitespace-only
If-Matchis rejected instead of becoming an unconditional delete; If-Matchcannot be combined with the internal recursivex-minio-force-deleteextension, whose prefix semantics cannot represent one object’s ETag condition; the HTTP handler rejects it and the storage layer also refuses any internal prefix-delete plus callback combination.
The batch XML decoder now also recognizes per-object <ETag> values. Until atomic per-item execution is implemented, any non-empty batch ETag rejects the entire request with NotImplemented before deletion begins. This is not batch conditional-delete support; it is a narrow data-safety guard against silently discarding a condition.
Rejected alternatives
Change only isETagEqual
It does not address the outer delete-marker fast path and risks changing several APIs that share the comparator.
Keep per-pool callbacks and aggregate the result
An aggregate error cannot roll back a copy already deleted by another pool. The condition applies to the logical current object, not independently to every physical copy.
Introduce a new condition object or transaction coordinator
The current feature has one If-Match condition, and CheckPrecondFn already expresses it. GET demonstrates the correct one-shot consumption pattern. A new DSL, state machine, or cross-pool transaction abstraction is unnecessary.
Complete every conditional-delete feature in one PR
DeleteObjects and policy conditions cross different API and repository boundaries. Combining XML parsing, per-item responses, IAM, quiet mode, and dependency publication with the core deletion repair would make the change harder to validate.
Test and acceptance contract
The minimally sufficient matrix is:
| Layer | Evidence |
|---|---|
| Condition helper | matching, mismatching, quoted ETag, wildcard, delete marker, non-DELETE method, and SSE-C client-visible ETag projection without content-decryption headers |
| Handler | wrong ETag returns 412 and preserves the object; matching ETag returns 204; missing key returns Not Found; blank conditions and conditional force-delete are rejected without mutation |
| Permission | delete-only plus specific ETag returns 403 and preserves the object; the same policy plus * succeeds |
| Single-pool storage | matching/mismatching condition, missing object, delete marker, one callback call, and refusal of a conditional prefix delete |
| Quorum | unreadable current object returns a quorum error, calls the callback zero times, and remains after disks recover |
| Versioning | a historical versionId condition still evaluates the current version |
| Two pools | 412 changes no pool; 204 removes all copies; one callback call in both cases |
| Batch safety guard | an unsupported per-object <ETag> returns NotImplemented and preserves every object |
The original PR’s quorum test merely took 8 of 16 disks offline and asserted that some error occurred. Delete write quorum was already unavailable, so the same test passed on main without conditional DELETE. The replacement asserts the specific quorum result, zero callback calls, and object survival after restoring the disks.
Independent adversarial review
Two read-only local Claude Code reviews used the Fable model at xhigh effort against the exact server diff and both design records. Both verdicts were GO WITH NON-BLOCKING NOTES, with no P0, P1, or P2 findings after the first round’s changes were applied.
The first review found the conditional force-delete bypass, whitespace-only downgrade, silent batch-ETag discard, missing versioned-success coverage, and the inherited degraded-old-pool limitation. Those findings produced the guards, tests, and limitation text above. The second review confirmed the outer atomicity boundary, error handling, auth split, batch-field blast radius, response-writer behavior, bilingual parity, and minimality. Its remaining actionable P3 was a hypothetical internal caller combining prefix deletion with a callback; the storage layer now rejects that combination too.
One reviewer sentence suggested that SSE-C without customer-key headers would necessarily fail the condition. Direct inspection showed the opposite established behavior: getDecryptedETag projects the stored client-visible suffix without asking to decrypt object contents. A focused regression test now pins that behavior. Remaining non-blocking notes are multiple-header normalization and the deliberate 501-before-auth error-ordering nuance. A live-AWS differential check for specific ETag versus a current delete marker and versionId plus If-Match would still be useful before claiming byte-for-byte behavioral parity beyond the published contract.
Deliberate follow-up scope
Per-object conditions in DeleteObjects
The AWS DeleteObjects API accepts an <ETag> per <Object> and returns each outcome under <Deleted> or <Error> in the same 200 response.
The safety patch adds an ETag field to ObjectToDelete only so the handler can detect the condition and reject the entire request before mutation. This closes the previous silent unconditional-delete behavior, but it does not implement AWS’s required per-object evaluation or mixed <Deleted> / <Error> response.
Full compatibility remains a separate high-priority change: evaluate every item against the logical current object under the correct lock, apply the exact-ETag permission rule per item, preserve quiet-mode behavior, and report each failed condition without blocking unrelated items.
The s3:if-match policy condition key
AWS policies can enforce conditional deletes. SILO’s silo-pkg does not yet define s3:if-match. Full support requires:
- the condition key and action map in
silo-pkg; - a new
silo-pkgrelease; - correct condition values for a single-delete header and batch per-item ETags;
- a server dependency update and policy compatibility tests.
That is a separate cross-repository deliverable, not a prerequisite for making single-object execution correct.
Complexity, benefit, and cost
Production code remains small: one DELETE-specific condition helper, one extra authorization check, roughly a dozen lines that consume the callback once at the outer layer, and narrow fail-closed guards for malformed/recursive and as-yet unsupported batch conditions. Most complexity belongs in tests because deletion spans pools, versions, markers, quorum, and permissions.
| Scope | Complexity | Main cost |
|---|---|---|
| This single-object repair plus batch safety guard | Medium | Regression coverage across the destructive hot path |
| Batch conditional delete | Medium-high | XML, per-item conditions, mixed responses, quiet mode |
| Policy condition key | Medium and cross-repository | silo-pkg release, server condition values, policy tests |
The benefit exceeds the cost. It removes a dangerous silent unconditional delete and places the condition at an existing global consistency boundary. Reusing the current outer-lock/latest-object pattern is the minimal, sufficient, and necessary design.
Merge and release gates
The single-object repair becomes mergeable only after:
- targeted condition, permission, versioning, quorum, and two-pool tests pass;
go test ./cmd,go vet ./cmd, formatting, and diff checks pass;- an independent adversarial review has no unresolved blocker;
- the contribution is organized on current
mainwith a valid author DCO sign-off; - DCO, Go CI, VulnCheck, and other required remote workflows are green;
- the PR description distinguishes complete
DeleteObjectsupport from the batch fail-closed guard and links the full batch/policy follow-ups.
A merge is still not a release. Users can rely on the behavior only after a corresponding SILO release, package, docker.io/pgsty/minio image, deployment, and real-client verification have independently completed.
Conclusion
Conditional DELETE is worth implementing. PR #12 has the right goal and the useful insight that fresh state must be checked under a lock. The required correction is the boundary: a client condition belongs to the logical current object and cannot be interpreted independently by every physical copy.
The selected design moves one callback to the erasureServerPools layer that already selects the current object, preserves the generic comparator, handles wildcard/delete-marker semantics explicitly, and adds the specific-ETag read permission. It changes no storage format, dependency, or public condition framework. The batch change is deliberately limited to refusing an unsupported condition before mutation; full batch execution and policy support remain separate work.
That is the minimum complexity needed to make the feature sufficient and safe.
4.2 - DSN-Only Database Notifications: A Compatibility Boundary for #53
This document is the product requirements and final design record for SILO issue #53. It records the accepted compatibility boundary, implementation, and verification for PostgreSQL and MySQL bucket-notification targets.
Decision
SILO will retain PostgreSQL and MySQL notification targets, but support exactly one current configuration form for each:
- PostgreSQL requires a complete
connection_string. - MySQL requires a complete
dsn_string.
The old five-field form — host, port, username, password, and database — remains unsupported by the current KV configuration system. SILO will not re-register those keys and will not synthesize a DSN from them during legacy migration.
The legacy migration contract is deliberately narrow:
| Legacy target | Result |
|---|---|
| Disabled | Ignore it; no target is emitted. |
Enabled with a non-empty connection_string or dsn_string |
Migrate only the canonical connection-string key and the other registered target settings. |
| Enabled with only discrete connection fields | Reject migration and abort server startup before the new configuration is activated, with an actionable error that names the subsystem and target but never prints a credential. |
This is a configuration-boundary decision, not removal of the database-notification feature.
Status: implemented in server commit f1ba68358; release pending.
Owner: SILO server repository.
Tracking: pgsty/silo#53.
Target: the next SILO patch release after implementation and verification.
Context
SILO inherited two generations of database-notification configuration from MinIO.
The pre-KV JSON configuration could describe a database connection either as a complete string or as five fields:
The current KV configuration exposes only the driver-native form:
This direction is not new. MinIO deprecated the five discrete fields in RELEASE.2020-04-10T03-34-42Z and instructed operators to move to connection_string or dsn_string. SILO’s current help tables, environment-variable documentation, and examples already present the complete string as the supported interface.
SILO is a new community fork with an explicit migration step. Its compatibility contract prioritizes the S3 and Admin APIs, current MINIO_* settings, on-disk data, and current KV configuration. It does not need to perpetuate every pre-2020 configuration spelling when a supported canonical form has existed for years.
The defect
Before the fix, the legacy migration helpers, SetNotifyPostgres and SetNotifyMySQL, wrote both forms into the new KV configuration. Even when the old target already had a complete connection string, the helpers also emitted all five discrete keys, usually with empty values.
The new parser rejects those keys because neither DefaultPostgresKVS nor DefaultMySQLKVS registers them. Key validation checks key presence, not whether the corresponding value is empty. Both legacy source forms therefore fail:
The failure is amplified by notification initialization. FetchEnabledTargets is fail-fast across notification subsystems: the first invalid subsystem returns an error and a nil target list. The caller logs the error and continues starting the object server, leaving healthy Webhook, Kafka, NATS, and other targets unavailable as well.
Merely returning an error from the two migration helpers does not fix that behavior. The error propagates through readConfigWithoutMigrate and initConfig, but initConfigSubsystem currently logs non-retriable configuration errors as “some features may be missing” and returns success. The server then starts without assigning globalServerConfig; notification failure is only one consequence, because region, storage class, compression, identity, and other stored settings may also be absent. The implementation must therefore carry a typed database-migration error to the startup boundary and make that error fatal. Classifying it as retriable is also wrong because the server would retry forever without any state change that could repair the configuration.
The resulting behavior is especially dangerous because object I/O still works. Operators can see a healthy S3 service while every configured event pipeline has stopped. Targets are never constructed, so delivery or later replay of events produced during the outage must not be assumed.
There is also a diagnostic-exposure issue. The unregistered password key has no sensitivity metadata and may be copied verbatim into health or diagnostic material. The registered connection_string and dsn_string keys are already treated as sensitive values.
Why the first fix was reverted
The first repair registered the five discrete keys and taught the parser to read them. That made migrated targets pass CheckValidKeys, and it appeared attractive because the target argument structures and constructors still contain code for the old fields.
It also broke the documented connection-string path.
The shared mc admin config set tokenizer discovers field boundaries by looking for registered key names. It is not fully quote-aware. Once port became a registered key, this valid input contained what looked like a second top-level field:
The tokenizer split at the port= inside the quoted value, truncated connection_string, and handed the remainder to the port parser. The command then failed with invalid port.
Under the current tokenizer, registering common words such as host, port, and password creates a direct conflict between the connection-string grammar and the top-level KV grammar. The attempted registration fix was therefore reverted. Re-registering those keys is not an acceptable solution.
Product judgment
Database notification targets are a specialized but useful capability. They provide a direct database-backed namespace view or access journal without requiring an external event bus. That remains valuable for small deployments and for users already operating PostgreSQL or MySQL.
The legacy spelling of their connection parameters has much less value. A five-field model cannot represent the useful range of driver options: TLS modes and certificates, connection timeouts, application names, Unix sockets, multi-host PostgreSQL settings, MySQL driver parameters, and future driver capabilities. Supporting both forms also creates precedence, merging, redaction, and testing questions that do not exist with one canonical value.
The complete string is the better abstraction boundary: SILO owns notification semantics, while the database driver owns connection syntax.
The product decision is therefore to keep the capability and remove the compatibility illusion. An unsupported legacy target must be rejected clearly; it must not be accepted and transformed into a configuration that later disables unrelated targets.
Goals
- Establish
connection_stringanddsn_stringas the only supported live configuration interfaces for database notifications. - Allow a legacy JSON target that already contains the canonical string to cross the migration boundary without modification to its connection semantics.
- Reject enabled discrete-only legacy targets before a partial or invalid KV configuration is activated.
- Replace the current silent runtime failure mode of #53 — healthy targets disabled while the server appears healthy — with an explicit startup-time failure that operators must resolve before the server runs.
- Ensure no migration error, log line, health report, or diagnostic bundle exposes a database password.
- Remove the ten Postgres/MySQL exceptions from the source-level unregistered-write audit.
- Make the compatibility boundary and operator remediation explicit in release and migration documentation.
Non-goals
- Supporting both DSN and discrete database fields in the current KV interface.
- Automatically synthesizing a DSN from old discrete fields.
- Rewriting the shared KV tokenizer.
- Changing
FetchEnabledTargetsfail-fast semantics in this patch. - Silently skipping an enabled database target and continuing with partial notification coverage.
- Removing PostgreSQL or MySQL notification targets.
- Deleting the legacy struct fields needed to decode and identify unsupported input. They remain on shared target argument structs that are also used by live constructors, whose discrete-field connection-string synthesis is unreachable from current KV configuration; those fields must not become supported configuration keys.
- Correcting ignored errors from the other eight legacy notification setters. Their pre-existing silent-skip behavior remains unchanged in this narrowly scoped database-migration patch and requires a separate audit and design decision.
Functional requirements
Current configuration
notify_postgresacceptsconnection_string;notify_mysqlacceptsdsn_string.- The five discrete keys remain unregistered and rejected by current configuration commands.
- Existing full strings must continue to support the database driver’s syntax, including parameters whose names contain
host,port,user,password, ordatabase. - No new public environment variables or KV keys are introduced.
- The declared legacy variables
MINIO_NOTIFY_POSTGRES_HOST/PORT/USERNAME/PASSWORD/DATABASEand their MySQL equivalents are not wired into current parsing and remain unsupported. They must not be documented as working alternatives to the complete-string variables.
Legacy migration
SetNotifyPostgresmust return without emitting a target when the legacy target is disabled.- For an enabled target,
SetNotifyPostgresmust require a non-emptyConnectionStringand write only registered Postgres keys. If both a canonical string and discrete fields are present, the canonical string wins and every discrete value is discarded. SetNotifyMySQLmust apply the equivalent rule toDSN.- Neither helper may emit
host,port,username,password, ordatabase. - A missing canonical string must return a typed or wrapped migration error identifying the subsystem and target name.
cmd/config-migrate.gomust check and propagate both helper errors. Ignoring them is forbidden.- No partially migrated configuration may be activated or persisted after either helper fails.
- Error text may name the required key and remediation, but must not include any connection-field value.
- The propagated typed migration error must abort server startup. It must not be downgraded to the non-fatal “some features may be missing” path in
initConfigSubsystem, and it must not enter the retriable-error loop. - Validation errors for a supplied canonical string follow the same startup-fatal and secrecy rules; wrapping must add target context without repeating the DSN or its components.
Recommended error shape:
Operator remediation
An operator encountering the error must choose an explicit remediation path. This applies both before an initial switch to SILO and when upgrading a deployment that is already running SILO: legacy migration output is not persisted, so the same old JSON source can re-enter migration on every start. A deployment that currently starts with notifications silently broken can therefore fail to start after this repair until the source configuration is corrected.
- On a compatible intermediate MinIO release, replace the old fields with
connection_stringordsn_string, verify the target, and then migrate to SILO. - Disable or remove the legacy database target, migrate the server, and recreate the target with the canonical string afterward.
- For a fresh SILO installation, create the target directly with the canonical string; no legacy migration is involved.
- For an existing SILO deployment that still reads a legacy JSON file, stop on the previous working release, back up the source configuration, then convert, disable, or remove the database target before starting the fixed release. Do not delete or rewrite unrelated configuration.
Documentation must not suggest that a discrete-only target will be converted automatically.
Availability trade-off
This decision intentionally turns one unsupported configuration from a degraded startup into a hard startup failure. The immediate availability cost is real: a server that previously served objects while all notifications were silently dead may refuse to start after the repair.
That cost is accepted because an object server that appears healthy while configured event sinks are absent creates silent, potentially unrecoverable downstream data loss. SILO is a new fork with an explicit migration boundary, and the discrete form has been deprecated since 2020. A fatal, actionable precondition is preferable to an upgrade that reports success with reduced notification coverage. The release note must make this startup behavior prominent; it must not be buried as an internal migration cleanup.
Security requirements
- The unsupported-input error must never format the legacy argument structure or its values.
- Tests must use a sentinel password and assert that it is absent from returned errors and captured logs.
- Migrated output must contain the registered sensitive connection-string key and no standalone password key.
- If a diagnostic bundle was exported from an affected deployment before this repair, operators should treat the database password as potentially disclosed and rotate it.
Alternatives considered
Register and parse the discrete fields
Benefit: preserves the old source form and uses already existing argument fields.
Rejected because: registration makes common field names visible to the shared tokenizer and corrupts quoted connection strings. It also expands the supported public configuration surface after the fields were deprecated in 2020.
Synthesize a canonical string during migration
Benefit: preserves discrete-only legacy installations.
Rejected because: it creates permanent code and test ownership for an obsolete input form, including PostgreSQL quoting, MySQL DSN formatting, socket and IPv6 behavior, defaults, and future driver drift. For a new fork with an explicit migration boundary, the benefit does not justify the continuing surface.
Skip only the unsupported target
Benefit: keeps the object server and other notification targets running.
Rejected because: silently discarding a configured event sink can cause unobservable and unrecoverable event loss. A clear migration failure is safer than an apparently successful upgrade with reduced notification coverage.
Change global notification fail-fast behavior
Benefit: limits the blast radius of future invalid targets.
Rejected for this change because: it neither repairs the database target nor closes the credential-exposure path, and it changes system-wide error semantics. It may be evaluated independently with its own operational contract.
Remove database notification targets
Benefit: removes the complete database-specific maintenance surface.
Rejected because: the targets remain useful and self-contained. The defect belongs to an obsolete configuration form, not to the notification capability itself.
Implementation scope
The server change should remain narrow:
- Update
internal/config/notify/legacy.goso the two database setters emit only canonical registered keys and reject enabled targets without a canonical string. - Update
cmd/config-migrate.goto propagate the two database-helper errors with subsystem and target context. - Define a typed database-migration error and update
cmd/server-main.gosoinitConfigSubsystemreturns it as fatal instead of logging and ignoring it. It must remain non-retriable. - Leave ignored errors from the other eight legacy notification setters unchanged in this patch; record them for a separate audit rather than expanding #53 implicitly.
- Remove all ten Postgres/MySQL entries from
knownUnregisteredWrites; the ratchet should become empty unless another independently justified legacy exception exists. - Add focused migration, startup, validation, secrecy, and coexistence tests.
- Update database-notification and migration documentation in
silo.pgsty.com.
The patch must not register the old keys, change the generic tokenizer, or refactor unrelated notification targets.
Acceptance criteria
The implementation is complete only when all of the following are demonstrated:
-
A legacy PostgreSQL target with a complete connection string migrates, passes
CheckValidKeys, and is returned byGetNotifyPostgresunchanged. -
A legacy MySQL target with a complete DSN does the equivalent.
-
Discrete-only enabled targets for both databases fail before target initialization with an actionable error containing the subsystem and target name, and server startup aborts.
-
Missing-string and malformed-string errors contain none of the sentinel host, username, password, database, or DSN values.
-
Disabled discrete legacy targets do not create configuration entries and do not block migration.
-
Migrated KVS output contains none of the ten discrete keys, including empty ones.
-
When a legacy target contains both a canonical string and conflicting discrete values, only the canonical string is migrated and no discrete sentinel appears in any output KVS value.
-
A
SetKVSregression test using the realDefaultPostgresKVSandDefaultMySQLKVSkey sets accepts a quoted connection string containingport=,host=, orpassword=. -
A configuration containing healthy Webhook, Kafka, or NATS targets cannot reach
FetchEnabledTargetswith an invalid migrated database target becausereadConfigWithoutMigratefails without yielding, persisting, or activating a partial configuration, and startup aborts on that typed error. -
initConfigSubsystemreturns the typed migration error; it neither logs-and-continues nor enters the retriable loop. -
knownUnregisteredWritesno longer contains Postgres or MySQL exceptions. -
The following verification passes:
The verbose
cmdoutput must show that tests with both prefixes actually ran; a zero-match warning is a failed acceptance check. The normal server CI suite must also pass. In the documentation checkout, runmake check.
Implementation result
Server commit f1ba68358 implements the accepted design without expanding the public configuration surface:
- the two legacy database setters emit only
connection_stringordsn_stringplus registered target settings; - disabled targets remain ignored, while enabled targets without a canonical string return a value-free
LegacyDatabaseTargetError; - only the two database migration errors are newly propagated;
- the typed error is non-retriable, escapes
initConfigSubsystem, and is classified as fatal byserverMainbeforelogger.FatalIfexits the process; - the ten Postgres/MySQL exceptions were removed from
knownUnregisteredWrites; - focused tests cover complete-string round trips, canonical precedence, discarded discrete values, secrecy, failed-migration atomicity, startup classification, and the real tokenizer key sets.
The final local Claude Code review used Claude Fable 5 at max effort and returned GO with high confidence and no blocking findings. Verification included the focused package set, race tests, go vet ./cmd, and the complete go test ./cmd -count=1 suite. The review authorized only the six-file server commit; publication remains a separate gate.
Cross-repository review found no implementation changes are required in pgsty/mc, pgsty/silo-pkg, or pgsty/silo-console: the client forwards configuration text, the package repository owns no notification schema, and Console already serializes its form into the canonical connection_string or dsn_string. The public reference and compatibility documentation is updated with this record.
Release and compatibility statement
The release note must describe this as an enforced compatibility boundary:
SILO database notification targets require
connection_stringfor PostgreSQL anddsn_stringfor MySQL. The pre-2020 discretehost/port/username/password/databaseform is not migrated. Convert or recreate such targets before switching the deployment to SILO.
Deployments already running SILO with an old-format source configuration are equally affected: after this release the server will not start until each enabled legacy database target is converted, disabled, or removed.
The issue should close only after the repair is present in a published server tag. A merged patch, a local site build, and a published release are separate completion gates.
Review record
Claude Fable 5 reviewed the first draft at xhigh effort on 2026-08-23 and returned approve with required changes. The required calibration was incorporated: startup-fatal propagation now extends through initConfigSubsystem; already-running SILO deployments are covered; the availability trade-off is explicit; canonical-string precedence, dead legacy environment variables, other ignored helper errors, and executable tests are specified.
The same model then completed a final source-backed verification pass. Final verdict: approve, with no blocking findings. It confirmed that the English and Chinese records are aligned, the requirements are implementable against the current server tree, and the acceptance criteria cover the startup, migration, parser-regression, and secrecy boundaries.
After implementation, a separate local Claude Code review using Claude Fable 5 at max effort traced the path through ExitFunc(1), inspected driver error behavior, ran the focused, race, vet, and full cmd suites, and returned GO with high confidence and no blocking findings.
4.3 - Preview Text, Never Execute It: SILO Console Text Preview PRD
Status: accepted design; implementation pending · Owner: pgsty/silo-console · Tracking: pgsty/silo#17 · Review: consensus of product, security, and frontend architecture reviews
SILO Console can preview images, PDFs, audio, and video, but not the small logs, text files, JSON documents, and XML documents that operators inspect every day. A correctly stored Content-Type does not help: these objects are classified as unsupported before the preview renderer is selected.
Restoring the old browser-native behavior would be easy. It would also be the wrong fix. An object in storage is controlled by the user who uploaded it. Loading that object as a same-origin HTML or XML document would turn a convenience feature into an execution boundary.
The accepted design therefore makes a stronger promise:
SILO previews eligible objects as bounded UTF-8 text. It never asks the browser to interpret their markup, MIME type, or file contents as a document.
This record fixes the product boundary, the resource limit, the security invariants, the implementation shape, and the evidence required before the feature can ship.
Decision
The first release will add a dedicated text preview type and a PreviewText component.
The contract is:
- Preserve every existing image, PDF, audio, and video classification.
- Only when the existing classifier returns
none, consider a text fallback. - Admit the four target extensions or four exact passive text MIME types.
- Fetch bytes through the ordinary authenticated download path, without
preview=true. - Enforce a hard application read limit of 1 MiB.
- Decode only strict UTF-8 and reject binary-looking content.
- Render one React text node inside a scrollable
<pre>. - Never use an iframe, HTML parser, XML parser, or HTML injection API.
- Show the complete object or no object; do not show a truncated JSON or XML document.
- Keep download available for files that are too large, invalidly encoded, or otherwise unavailable.
No Console API or S3 API change is required. The backend inline MIME allowlist is not expanded.
Current behavior
The defect is present in SILO Console v2.1.1, the version currently pinned by SILO when this design was written.
The frontend preview union contains only:
Its extension table contains media formats, but not .log, .txt, .json, or .xml. Its MIME classifier likewise ignores text/plain, application/json, application/xml, and text/xml.
Runtime verification produced this split:
| Object | Frontend result | Console download response |
|---|---|---|
.log / text/plain |
none |
inline, SAMEORIGIN |
.txt / text/plain |
none |
inline, SAMEORIGIN |
.json / application/json |
“Preview unavailable” | inline, SAMEORIGIN |
.xml / application/xml |
none |
attachment, DENY |
The object-detail action also uses the wrong conjunction when deciding whether Preview should be disabled. An authorized user can click Preview for an unsupported object and receive only the unavailable message; in other combinations, the UI can offer an action before the server rejects it.
The preview component still contains a generic same-origin iframe fallback. It is unreachable under the current type union, so the current defect is not an exploitable text-preview XSS. The dead branch is nevertheless hazardous: adding text to the union and letting it fall through would reactivate precisely the document-loading behavior this design rejects.
Root cause
This is contract drift across three independently evolved layers.
Classification drift
The browser code decides eligibility from filename and object metadata, but its closed type union has no text representation. Correct metadata cannot select a renderer that does not exist.
Response-policy drift
The Console server separately decides whether a response may be inline. It still treats plain text and JSON as safe passive MIME types, while XML and HTML remain attachments. That server decision is not reflected in the frontend classifier.
Renderer drift
The old generic iframe remains after the set of reachable preview types became media-only. The code therefore suggests a capability that the type system can no longer invoke.
The repair must realign the three layers without making MIME metadata a security boundary.
Why same-origin iframe preview is rejected
X-Frame-Options: SAMEORIGIN is not a sandbox. It controls who may embed a response; it does not limit what code inside a same-origin frame can do.
If uploader-controlled HTML, XHTML, SVG, or active XML were ever served as an inline same-origin document, it could act with the Console origin. An HttpOnly cookie would prevent direct cookie reads, but it would not prevent authenticated same-origin requests. A permissive or accidentally widened MIME rule would then turn stored content into stored application code.
nosniff, Content Security Policy, and Content-Disposition remain useful defense in depth, but none replaces the core invariant:
Product contract
The feature is a read-only text viewer, not a web previewer and not an online editor.
The user should be able to:
- open a small eligible object from either the list or object-detail surface;
- read whitespace-preserving source text in the existing preview modal;
- select and copy text using browser-native behavior;
- understand whether a failure is caused by size, encoding, permission, object replacement, or network error;
- download the original bytes at any time.
The user must never be led to believe that:
- formatted JSON is the stored object;
- a partial XML document is complete;
- replacement characters are original bytes;
- an unsupported encoding has been decoded faithfully;
- an active HTML/XML document has been safely “sanitized” and executed.
Goals and non-goals
Goals
- Preview small logs, text, JSON, and XML without a local download.
- Keep object content inert regardless of extension, MIME, or payload.
- Bound retained response bytes and rendered text to 1 MiB.
- Preserve the stored text rather than silently reformatting it.
- Keep list and detail actions consistent with permissions and type eligibility.
- Support current object versions and explicitly selected historical versions.
- Preserve anonymous-access and subpath-hosting behavior.
- Ship the feature in Console first, then consume that exact Console revision in SILO.
Non-goals
- HTML or XHTML rendering.
- XML parsing, XSLT, external entities, or schema validation.
- Markdown rendering.
- JSON pretty-printing.
- YAML or CSV-specific behavior.
- Editing or saving.
- Syntax highlighting, line numbers, search, folding, ANSI rendering, or linkification.
- Head, tail, or truncated previews for large objects.
- Lossy decoding or automatic detection of GBK, UTF-16, Latin-1, or other encodings.
- A new backend text-preview endpoint.
- Changes to the existing SVG, media, PDF, download, share, or storage contracts.
An object such as notes.md may still be shown as raw text when its exact MIME type is text/plain. It does not gain Markdown semantics.
Eligibility contract
Eligibility is deliberately two-stage.
Stage 1: preserve the legacy media decision
Run the current image, PDF, audio, and video classifier unchanged. If it returns anything other than none, return that result.
This preserves historical behavior for conflicting filename and MIME combinations.
Stage 2: apply text fallback
Only after the legacy result is none:
-
Reject final extensions
.html,.htm, and.xhtml. -
Match the final filename extension case-insensitively against:
.log.txt.json.xml
-
Normalize Content-Type by removing parameters, trimming whitespace, and lowercasing it.
-
Match the normalized MIME exactly against:
text/plainapplication/jsonapplication/xmltext/xml
An allowed extension or an allowed exact MIME is sufficient. Broad matches such as text/, substring tests, and application/+json are forbidden in this release.
The resulting matrix is normative:
| Filename and MIME | Result | Reason |
|---|---|---|
report.txt + image/png |
image | Existing media decision wins. |
report.json + application/pdf |
Existing media decision wins. | |
server.LOG + application/octet-stream |
text | Allowed extension, case-insensitive. |
no extension + application/json; charset=utf-8 |
text | Exact normalized MIME. |
page.html + text/plain |
none | Explicit active-extension exclusion. |
page.txt + text/html |
text | Extension admits it; HTML source remains inert text. |
notes.md + text/plain |
text | Exact MIME admits raw text, not Markdown rendering. |
image.svg + image/svg+xml |
existing image path | No new text or iframe path. |
Filename and MIME affect product eligibility only. They never select an executable rendering mode.
Resource contract
The binary limit is:
Exactly 1 MiB is eligible. 1 MiB plus one byte is not.
Known sizes
- If the selected version has a known size greater than the limit, do not request its body.
- If its known size is zero, show the empty-file state.
- If its known size is within the limit, begin a bounded request.
- An absent size is not the same as zero; it enters the bounded unknown-size path.
The current list-to-modal handoff must therefore preserve undefined rather than converting it to zero with a truthy fallback.
Bounded request
For a small or unknown size, request:
The extra byte is an over-limit sentinel.
The client must:
- Inspect
Content-RangeandContent-Lengthwhen present. - Read the response as a stream rather than calling
response.text()or building a complete Blob. - Retain at most the limit plus the sentinel byte.
- Cancel immediately when the sentinel byte is observed.
- Enforce the same limit when the server ignores Range and returns 200.
- Render only after end-of-stream proves that the complete object is within the limit.
An over-limit object opens an explanation state with its known size, the 1 MiB policy, and a Download action. It never shows a prefix fragment.
Request identity and cancellation
A preview request is identified by:
The request must use the existing generated API client or an equivalent base-path-safe helper so that it preserves:
- same-origin credentials;
- the current Console subpath;
version_id;- anonymous-mode
X-Anonymous: 1; - current error handling and permission boundaries.
Close, object change, version change, bucket change, and component unmount must abort the active request and clear the old content.
Abort alone is insufficient. A generation token or invalidation flag must also prevent a response that already completed reading or decoding from updating a newer preview.
An aborted request is not an error and must not produce an error toast.
Encoding and fidelity
The first release supports strict UTF-8 only:
Requirements:
- handle the UTF-8 BOM without displaying it;
- preserve Unicode text, emoji, tabs, LF, and CRLF;
- reject invalid UTF-8 rather than inserting replacement characters;
- reject decoded NUL characters as binary or unsupported content;
- do not guess another encoding;
- do not log or persist object text;
- always retain Download as the original-byte escape hatch.
The unsupported-encoding state should explain:
This object is not valid UTF-8 text or contains binary data. Download it to inspect the original bytes.
JSON and XML are displayed exactly as decoded source text. The first release must not run JSON.parse followed by JSON.stringify: that can alter unsafe integers, duplicate keys, whitespace, lexical forms, and the text users copy.
Safe renderer
The success state renders one text node:
The implementation must not use:
- iframe, object, or embed;
dangerouslySetInnerHTMLorinnerHTML;DOMParseror an XML parser;- Markdown or HTML rendering;
- an HTML data/blob URL;
- per-line or per-token spans;
- automatic links, ANSI escapes, or syntax markup.
One bounded text node keeps the DOM cost predictable and the security property inspectable.
The preformatted region uses a monospace font, preserves whitespace, defaults to no wrapping, owns both scrollbars, is keyboard focusable, and supports native selection and copy. No-wrap is intentional: it preserves aligned logs and avoids expensive layout of a single very long line.
UI states and permissions
The Preview action is enabled only when:
The object-detail conjunction bug must be fixed, and list and detail surfaces must share the same eligibility function.
An eligible over-limit object still offers Preview. The modal explains why content is not loaded; disabling the button would leave the user unable to distinguish size, permission, and type failures.
The modal distinguishes:
| State | Required behavior |
|---|---|
| Loading | Accessible busy state; no stale text. |
| Success | Scrollable raw text plus Download. |
| Empty | Explicit “File is empty” state. |
| Too large | Object size, 1 MiB limit, Download; no body request when size is already known. |
| Invalid UTF-8 / binary | Dedicated explanation and Download. |
| Forbidden | Permission-specific message; no retained text. |
| Not found / replaced | Object-change message; no retained text. |
| Network / server error | Actionable retry/download state. |
| Aborted / closed | Silent cleanup. |
HTTP error bodies must never be decoded and displayed as object content.
All new user-facing strings go through the existing translation layer and ship in English and Chinese together. The content region and controls must remain usable in light and dark themes and at narrow widths.
Functional and security requirements
Functional requirements
- FR1: Existing media and PDF classification remains unchanged.
- FR2: The text fallback follows the normative extension/MIME matrix.
- FR3: Eligible complete objects up to 1 MiB render as strict UTF-8 source.
- FR4: Over-limit objects render no partial content.
- FR5: Empty objects have a distinct successful empty state.
- FR6: Current and selected historical versions use the same version for metadata, size, and body.
- FR7: Anonymous access and subpath hosting retain their current request behavior.
- FR8: List and detail actions apply the same type and permission decision.
- FR9: Download, share, media, PDF, and storage behavior do not change.
Security requirements
- SR1: Object bytes can reach the DOM only through text content.
- SR2: Text Preview contains no document renderer or parser.
- SR3: At most 1 MiB plus one sentinel byte is retained.
- SR4: Closing or changing identity invalidates every previous response.
- SR5: Invalid UTF-8 and NUL content are not shown as faithful text.
- SR6: Errors, Redux, local storage, logs, and telemetry never retain preview text.
- SR7: Server authorization remains authoritative for direct requests.
- SR8: No CSP or backend inline MIME relaxation is introduced.
Implementation scope
Expected Console changes:
- Refactor preview classification so the current media decision is preserved and text is an explicit fallback.
- Add
textto the preview type union. - Add a dedicated
PreviewTextcomponent with streaming bounds, strict decode, request cancellation, and explicit states. - Route text objects explicitly to that component.
- Remove the unreachable generic iframe fallback.
- Fix the object-detail Preview disable expression and share eligibility logic with the list surface.
- Preserve unknown size instead of coercing it to zero.
- Add English and Chinese strings.
- Add classification, component, resource, security, permission, version, and browser tests.
Expected unchanged areas:
- Console and S3 API paths;
- the backend
safeMimeTypeslist; - Content Security Policy;
- object storage and metadata formats;
- image, PDF, audio, video, download, and share handlers;
- external frontend dependencies.
If a future product requires tailing, server-side transcoding, organization-wide policy, or reliable behavior through proxies that ignore Range, a dedicated server endpoint may be designed separately.
Rejected alternatives
Keep text preview disabled
Benefit: no new code or browser memory use.
Rejected because: logs and configuration objects are a routine object-storage workflow, and download-only inspection is an avoidable Console regression.
Reuse the same-origin iframe
Benefit: minimal code and browser-native presentation.
Rejected because: it turns uploader-controlled content and mutable MIME metadata into a same-origin document boundary. It also leaves resource use unbounded.
Add a backend preview API now
Benefit: central server-side limits and normalized text responses.
Rejected for the first release because: the user already has object-read permission, and the existing download endpoint provides versioning, authorization, and Range. A new API would duplicate contracts without establishing a new data-access boundary.
Show the first 1 MiB of a large object
Benefit: better large-log convenience.
Rejected because: partial JSON/XML is structurally misleading, UTF-8 boundaries need additional handling, and a single “preview” action would no longer mean complete content.
Decode invalid UTF-8 with replacement characters
Benefit: some damaged or legacy logs remain partially readable.
Rejected because: copied text would no longer faithfully represent the stored object. Lossy viewing and other encodings require a separate, explicit product mode.
Auto-format JSON
Benefit: more readable indentation.
Rejected because: parse/stringify can alter numbers, duplicate keys, lexical representation, and copied content. A future opt-in formatted view may sit beside, never replace, the raw default.
Add Monaco or another code editor
Benefit: line numbers, search, highlighting, and folding.
Rejected because: bundle, worker, CSP, and maintenance costs exceed the needs of a bounded read-only preview. A native <pre> is smaller and easier to audit.
Acceptance and test plan
Classification matrix
Automated tests must lock every normative matrix row, extension case handling, MIME parameter stripping, explicit HTML/XHTML denial, and unchanged media conflicts.
Resource tests
Cover:
- 0 bytes;
- 1 byte;
- exactly 1,048,576 bytes;
- 1,048,577 bytes;
- known over-limit size with zero body requests;
- unknown size;
- 206 with a revealing
Content-Range; - server ignores Range and returns 200;
- missing or false
Content-Length; - close and identity changes during streaming.
No case may retain or render more than the complete allowed object.
Encoding and fidelity tests
Cover UTF-8 Chinese, emoji, tabs, LF, CRLF, BOM, invalid byte sequences, NUL bytes, JSON unsafe integers, duplicate keys, original whitespace, XML declarations, DOCTYPE, CDATA, and stylesheet processing instructions.
The raw success view must preserve decoded text. Invalid and binary cases must show their dedicated state.
Security tests
Payloads containing <script>, event attributes, iframe tags, SVG handlers, XML stylesheets, external entities, and suspicious URLs must:
- appear literally in
<pre>.textContent; - create no corresponding DOM elements;
- execute no script or dialog;
- cause no object-content-originated request;
- encounter no iframe, object, embed, HTML parser, or XML parser in Text Preview.
Permission and race tests
Verify:
- no
GetObjectmeans no usable action and no retained body; - historical versions require their corresponding permission;
- metadata and body use the same version ID;
- a late old response cannot replace a new object’s preview;
- 401, 403, 404, 416, and 5xx bodies never become preview content;
- anonymous access and Console subpaths do not regress.
Browser regression
Use a real SILO/Console test instance to inspect both English and Chinese routes, light and dark themes, and narrow and desktop widths. Media, PDF, download, share, and version workflows require smoke coverage alongside the new text states.
Delivery and completion gates
The change belongs to pgsty/silo-console, even though the user report is tracked in the SILO server repository.
Delivery is staged:
- Merge the focused Console source and test change.
- Pass TypeScript checking, production build, automated matrices, and real-browser security regression.
- Update Console release notes and regenerate the actual embedded web assets.
- Publish a Console version; a minor release is appropriate for the new visible capability.
- Update SILO’s
github.com/minio/console => github.com/pgsty/silo-consolereplacement to the exact new pseudo-version. - Build a SILO candidate from that exact dependency and repeat integration checks.
- Publish the SILO binary and image, naming the first version that contains the feature.
These are separate states:
| Gate | Meaning |
|---|---|
| Console PR merged | Implementation exists in source. |
| Console assets/tag published | Console is independently consumable. |
| SILO dependency updated | SILO main has integrated the change. |
| SILO release published | Users can obtain the feature. |
Issue #17 should not be described as fixed for users merely because a local preview or Console source PR exists.
Trade-off summary
The accepted design favors:
- explicit scope over a generic browser viewer;
- complete small files over partial large files;
- source fidelity over automatic formatting;
- strict UTF-8 over silent lossy decoding;
- one inert text node over a full editor;
- the existing download API over a new backend contract;
- a verifiable security invariant over convenient same-origin rendering.
The cost is real: large logs and legacy encodings still require download, and the first release has no search, line numbers, wrapping toggle, or highlighting. Those omissions are deliberate. They make the feature small enough to audit and strong enough to trust.
Review record
The design was independently reviewed from three perspectives:
- product scope, delivery, and acceptance;
- security and frontend architecture;
- compatibility and current-source verification.
The reviewers initially differed on MIME-only eligibility and lossy UTF-8 fallback. After cross-review they reached a single contract:
- existing media classification wins;
- text fallback accepts the four target extensions or four exact normalized MIME types;
- HTML/XHTML extensions are explicitly excluded;
- strict UTF-8 and NUL rejection are required;
- lossy viewing is deferred to a separate proposal.
No unresolved design question remains. Implementation may proceed against this record.
4.4 - One Endpoint, Two Privileges: Separating User and Group Status
This document records the discussion, repair, and final authorization design for upstream issue minio/minio#21478 and SILO PR #73.
Status on 2026-08-26: SILO PR #73 was merged as
2e2377d1c, preserving the signed-off repair commit58735ee38. All eight reported checks passed. Upstream issue #21478 and PR #21482 remain open, butminio/miniois archived and read-only, so no further issue comment or merge can be made there.
Group follow-up on 2026-08-28: final release review found the same fixed-action defect inset-group-status. Signed-off server commitd98250110now selectsadmin:EnableGrouporadmin:DisableGroupfrom the requested target state and adds a real four-way IAM authorization test. Local verification and independent review are complete; push, remote CI, merge, tag, and delivery remain pending.
Scope: authorize enabling and disabling a user with their respective existing Admin Actions. Do not change the route, status values, account storage, replication record, or client API.
Security property: possessingadmin:DisableUsermust not grant the ability to enable an account, and possessingadmin:EnableUsermust not grant the ability to disable one.
Release boundary: merge, tag, release package, container image, deployment, and production verification remain separate gates.
Too Long; Didn’t Read (TL;DR)
SILO exposes both admin:EnableUser and admin:DisableUser, but the shared set-user-status handler historically authorized every request with admin:EnableUser. A policy that granted only admin:DisableUser therefore could not disable an account. The workaround was to grant admin:EnableUser as well, which destroyed the least-privilege boundary that the two action names promised.
The selected repair derives exactly one required action from the requested target state before authorization:
| Requested status | Required action |
|---|---|
enabled |
admin:EnableUser |
disabled |
admin:DisableUser |
| invalid or unknown | admin:EnableUser, preserving the previous authorization-before-validation default |
The handler then calls validateAdminReq once. A four-way IAM test proves both positive operations and both denied cross-action operations. This is intentionally stricter than preserving the accidental historical behavior in which an Enable-only policy could also disable users.
The same rule now applies to group status:
| Requested group status | Required action |
|---|---|
enabled |
admin:EnableGroup |
disabled |
admin:DisableGroup |
| invalid or unknown | admin:EnableGroup, preserving the previous authorization-before-validation default |
Before the follow-up, an EnableGroup-only principal could disable a group, while a DisableGroup-only principal received AccessDenied for that exact operation. The group repair uses the same one-selector, one-authorization design rather than treating the two actions as aliases.
The reported defect
The Admin API uses one route for both state transitions:
Before the repair, the handler checked one fixed action before reading the requested status:
The later call to SetUserStatus correctly received either enabled or disabled, but authorization had already treated both as Enable operations. admin:DisableUser existed in the policy vocabulary and documentation while being ineffective for this endpoint on its own.
Issue #21478 supplied the practical counterexample: an operator wanted a policy that could disable accounts during an incident without being able to restore them. A policy containing admin:DisableUser received AccessDenied; adding admin:EnableUser made the request work, but also gave the operator the more powerful recovery transition that the policy intentionally withheld.
This is not a missing convenience permission. It is a mismatch between the policy model and the enforcement point:
Why two actions must mean two capabilities
An account state transition has direction. Disabling is commonly delegated to incident responders, fraud controls, compliance automation, or a break-glass process. Enabling restores access and may require a separate approver.
If either action authorizes both transitions, a policy author cannot express that separation. The server would publish two names while enforcing one combined capability. The design contract is therefore strict:
| Principal policy | Disable target | Enable target |
|---|---|---|
admin:DisableUser only |
allow | deny |
admin:EnableUser only |
deny | allow |
| both actions | allow | allow |
| neither action | deny | deny |
The built-in consoleAdmin policy grants admin:*, so full administrators retain both operations. The compatibility impact is limited to custom restricted policies that relied on the old accidental behavior.
The public PBAC reference now states the same contract for admin:EnableUser and admin:DisableUser.
Design goals and non-goals
Goals
- Make both existing Admin Actions enforceable according to their names.
- Preserve least privilege in both directions.
- Perform one authorization decision and write at most one authorization error.
- Preserve the route, request values, response format, self-mutation guard, IAM storage call, and site-replication hook.
- Encode the contract in tests that fail if the two permissions are broadened or swapped again.
Non-goals
- split the endpoint into separate enable and disable routes;
- add a new combined action or change policy syntax;
- change user status persistence or replication;
- redesign Console permissions;
- infer release, image, deployment, or production delivery from a source merge.
Alternatives considered
Keep checking admin:EnableUser for both states
This preserves behavior but leaves admin:DisableUser unusable and forces over-privileged policies. It is the defect, not a compatibility contract worth retaining.
Require both actions for either transition
This makes the two labels decorative and prevents delegated disable-only operation. It is stricter in quantity but weaker in expressiveness and least privilege.
Try Enable authorization, then retry Disable authorization
Upstream PR #21482 attempted this shape for a disabled request. It first called validateAdminReq with EnableUser, then called it again with DisableUser if the first result was nil.
That helper has an important contract: when it returns a nil object layer, it has already written an error response. A Disable-only request can therefore commit a 403 response before the second authorization succeeds and the handler proceeds to mutate account state. Authorization fallback must never continue after an error response has been committed.
Accept either Enable or Disable for a disabled request
validateAdminReq already accepts multiple actions and succeeds if any one is allowed, so compatibility behavior could be implemented safely with one variadic call. That would let Disable-only policies work while preserving the historical ability of Enable-only policies to disable.
SILO rejected this option because the historical ability was the enforcement bug. It would solve the reporter’s positive case but retain a cross-action privilege that contradicts the two-action model. Operators who want both transitions can grant both actions explicitly.
Validate the status before authenticating
Rejecting unknown status values first would change error precedence: a caller that previously had to pass the Enable authorization gate could now receive a validation result before authorization. The repair does not need that broader behavioral change.
Unknown values therefore retain admin:EnableUser as the authorization default. Valid disabled is the only value that selects admin:DisableUser; the existing IAM layer remains responsible for rejecting invalid status values after authorization.
The selected implementation
The repair adds a pure selector:
The handler reads the route variables, selects the action, and authorizes exactly once:
Everything after the gate remains unchanged:
- a caller still cannot enable or disable its own account;
globalIAMSys.SetUserStatusvalidates and persists the requested status;- site replication records the same status and timestamp;
- response and audit behavior use the existing path.
The selector depends only on the requested target state. It does not load the current user, infer a transition from stored state, or make authorization depend on whether the target exists. This keeps authorization deterministic and avoids a read-before-authentication dependency.
Why the repair is safe
The correctness argument consists of five invariants:
- Every valid status maps to exactly one Admin Action.
validateAdminReqis invoked once, so a failed authorization cannot be followed by mutation.- The mutation call is reachable only after the selected action succeeds.
- Invalid status values preserve the old Enable authorization boundary and are still rejected by the existing status-validation path.
- No storage, replication, wire, or client contract changes; only the permission required to reach the existing mutation changes.
The change is a deliberate authorization tightening for Enable-only custom policies that used the disable operation. That tightening is the mechanism that makes admin:DisableUser a real independent capability.
Test design
Pure action mapping
The unit test fixes three selector cases:
| Input | Expected action |
|---|---|
enabled |
EnableUser |
disabled |
DisableUser |
| invalid | legacy EnableUser default |
Four-way IAM authorization matrix
The integration test creates separate users and policies, then exercises the real Admin API:
- a Disable-only client successfully disables a target;
- the same client receives
AccessDeniedwhen enabling it; - an Enable-only client successfully enables the target;
- the same client receives
AccessDeniedwhen disabling it.
Positive assertions alone would not prove least privilege: both policies could accidentally authorize both states and still pass. The two negative cross-action assertions are the security regression tests.
The test removes every temporary user and policy after execution. It runs inside the existing IAM server suite, so it covers request signing, policy attachment, handler authorization, persistence, and Admin-client error decoding rather than testing only the helper.
Repair and verification record
The server checkout originally contained unrelated dependency, generated-credit, checksum-test, and security-document changes, while local main was behind the remote. The two user-status files were isolated into a clean worktree based on current origin/main; no unrelated file entered the repair commit.
Local verification passed:
The signed-off commit 58735ee38 was pushed in PR #73. Its eight remote checks all passed:
- DCO sign-off;
- format, build, and vet;
- lint and generated files;
cmd/tests;internal/tests;- race detector and S3 Select;
- cross compilation;
- vulnerability analysis.
The PR was merged with the repository’s normal merge strategy as 2e2377d1c. Local main was then fast-forwarded only after the two original working files were byte-for-byte and patch-ID identical to the merged result. The unrelated local changes remained intact, and the temporary worktree and task branch were removed after the code became recoverable from main and PR #73.
Least-privilege policy examples
Disable-only operator
This principal can inspect and disable another user, but cannot enable it.
Enable-only operator
This principal can inspect and enable another user, but cannot disable it. Grant both actions explicitly to roles responsible for the complete account lifecycle.
Group-status follow-up
The group endpoint has the same shape as the user endpoint:
It also publishes two existing actions, admin:EnableGroup and admin:DisableGroup. The inherited handler nevertheless authorized every request with EnableGroup before reading status. This was not merely a dead permission: it reversed least privilege in both directions. The wrong principal could disable a group, and the intended disable-only principal could not.
The follow-up adds setGroupStatusAdminAction, deliberately matching setUserStatusAdminAction:
The integration test creates separate EnableGroup-only and DisableGroup-only administrators and a real target group. It proves:
- DisableGroup-only can disable;
- DisableGroup-only cannot enable;
- EnableGroup-only can enable;
- EnableGroup-only cannot disable.
The suite exercises signed Admin requests, policy attachment, handler authorization, IAM mutation, response decoding, and cleanup. Invalid status still selects the legacy Enable action before the existing validation error, so the change does not expose a new pre-authentication oracle. The successful site-replication hook remains after mutation and is not called for denied requests.
This follow-up changes no user behavior and introduces no new policy action. It makes the two already documented group actions enforce the same state-specific contract as their user counterparts.
Compatibility and migration
No client or API migration is required. The endpoint, query parameters, status strings, success response, and Admin-client method are unchanged.
Policy review is required for restricted administrative roles:
- a role that should only disable users needs
admin:DisableUser; - a role that should only enable users needs
admin:EnableUser; - a role that must do both needs both actions;
consoleAdminand otheradmin:*policies are unaffected;- a legacy custom policy containing only
admin:EnableUsercan no longer use that permission to disable users and must addadmin:DisableUserif both operations are intended.
The equivalent rules now apply to group-management roles:
- a role that should only disable groups needs
admin:DisableGroup; - a role that should only enable groups needs
admin:EnableGroup; - a role that must do both needs both actions;
- a legacy EnableGroup-only role can no longer disable groups.
This is a source-level compatibility change in authorization behavior, not a wire-protocol break.
Upstream disposition
As of this record, upstream issue #21478 and PR #21482 are still displayed as open. The upstream repository is archived and read-only. An attempt to leave the single-authorization analysis on the PR was rejected by GitHub because archived, locked discussions cannot accept comments.
The upstream artifacts remain useful provenance but are no longer an actionable delivery path. SILO owns its implemented semantics, tests, merge, release note, and eventual production verification.
Delivery state
| Gate | User repair | Group follow-up on 2026-08-28 |
|---|---|---|
| Design decision | complete | complete |
| Implementation and local tests | complete | complete |
| Independent adversarial review | complete | complete, GO |
| Signed-off commit | complete | local d98250110 |
| Push, PR CI, and merge | complete | not established |
| Tagged SILO release | not established | not established |
| Release package or container image | not established | not established |
| Deployment | not established | not established |
| Production behavior | not established | not established |
| Upstream merge | unavailable; repository archived | not applicable |
Conclusion
The repairs make the authorization model tell the truth. Enabling and disabling users or groups are opposite state transitions with different operational risk, and SILO already exposes different policy actions for each direction. Each handler must therefore select the action from the requested target state and authorize once before mutation.
The code change is small because the design boundary is clear. The durable result is larger: an explicit permission matrix, rejected compatibility alternatives, an invalid-input rule, a four-way integration test, a clean merge record, migration guidance, and an honest release boundary.
4.5 - Config Environment Files Are Not Shell Scripts
This record defines the startup contract for MINIO_CONFIG_ENV_FILE and explains the compatibility repair committed in SILO as ce456dba0.
Status on 2026-08-28: implementation, focused tests, the complete
cmdandinternalsuites, tagged tests, race tests, vet, lint, generated-file checks, rebrand guards, build, and an independent local Fable Max review are complete. The server commit exists locally; push, remote CI, merge, tag, package, image, deployment, and production verification remain separate gates.
Scope: environment-file parsing and named-target discovery only. No configuration key, subsystem, value, precedence, storage format, or client API changes.
Compatibility rule: the file is a SILO input format. Supporting an optionalexportprefix does not make it a POSIX shell program.
Too Long; Didn’t Read (TL;DR)
SILO can load startup variables from a file:
The parser accepts assignments such as:
The last two names are important. Multi-target configuration appends the target name verbatim after an underscore. The configuration subsystem does not restrict a target to a shell identifier; names containing -, ., :, digits, or printable Unicode can be discovered and resolved exactly.
A hardening change accidentally validated every key as [A-Za-z_][A-Za-z0-9_]*. It made my-hook invalid and stopped the server during restart even though the previous loader and the configuration target model accepted it. The repair validates what SILO actually needs instead:
- the name is non-empty, valid UTF-8, and made of visible non-whitespace characters;
=and NUL are not allowed in a name;- NUL is not allowed in a value;
- invalid input reports file and line without reporting the value;
- the complete file is parsed before any assignment is applied.
Why the regression was real
The environment-file loader calls os.Setenv after parsing. An operating-system environment is a list of strings, not a shell variable namespace. Shell assignment syntax is narrower because the shell must tokenize and expand variable names in its own language.
Named SILO configuration targets are built differently:
For example:
Target discovery lists variables by the fixed parameter prefix and treats the remaining suffix as the target name. Target lookup reconstructs the same name without uppercasing or sanitizing that suffix. Rejecting - in the file parser therefore broke a valid discover-to-resolve path; it did not protect a shell evaluation path because no shell evaluates the file.
The failure is operationally sharp. MINIO_CONFIG_ENV_FILE is loaded only at startup. A server can continue running with an old process environment, then fail on its next restart after the file or binary changes. Startup must fail on malformed input, but it must not invent a narrower target grammar than the configuration system.
The file grammar
Lines and comments
- blank lines are ignored;
- a line whose first non-whitespace character is
#is ignored; - an optional standalone
exportfollowed by whitespace is removed; exportFOO=valueremains the keyexportFOO; it is not mistaken for the prefix;- the first
=separates key and value, so additional=characters remain part of the value.
The file is not a shell. It does not perform variable expansion, command substitution, backslash processing, or inline-comment interpretation.
Keys
Surrounding whitespace around the key is removed. The remaining key must:
- be non-empty valid UTF-8;
- contain only Unicode graphic characters;
- contain no whitespace,
=, NUL, control, or invisible format characters.
This preserves OS-compatible names and multi-target suffixes while rejecting visually empty or structurally ambiguous keys. A key beginning with a digit or punctuation is accepted by the parser; SILO still reads only the exact names used by its configuration and runtime components.
Values and quoting
Unquoted values are trimmed. To retain leading or trailing spaces, quote the complete value with matching single or double quotes:
The parser removes one matching outer quote pair. It does not interpret escapes inside the quoted value. NUL is always rejected because it cannot be represented in an environment entry.
Failure and secrecy contract
Syntax errors stop startup. Diagnostics include the file path, line number, and the invalid key or error class, but never the value. A password on a malformed line must not be copied into logs.
Parsing is all-or-nothing: a syntax error returns no entries, and assignment starts only after the complete file has parsed. If the operating system rejects a validated assignment, SILO also stops startup and identifies the key and file. Since the process exits, it never serves requests with a partially loaded environment.
The file itself remains a privileged secret-bearing input. Operators must protect it with appropriate ownership and mode; parser validation is not a substitute for filesystem permissions.
Regression matrix
The committed tests cover:
- spaces and tabs around
=; - quoted values with significant spaces;
- standalone
export, including Unicode whitespace after it; - keys beginning with
_, a digit, or punctuation; - named targets using
-,.,:, and Unicode; - exact named-target discovery through the configuration subsystem;
- empty keys, whitespace, NUL, and invisible format characters;
- NUL values;
- multiple
=characters in URLs and tokens; - file-and-line diagnostics that redact values;
- all-or-nothing parse results.
The implementation passed the complete local server verification matrix and a read-only adversarial review. Windows-specific os.Setenv behavior has not been exercised on a Windows runner; unsupported platform rejection remains fail-fast rather than silent.
Compatibility and delivery
No configuration migration is required. Existing ordinary environment names behave unchanged. Files using shell-style whitespace become more predictable, and previously accepted named targets work again.
The visible compatibility changes are intentional:
- invalid or invisible names now fail instead of being silently ignored;
- unquoted surrounding value whitespace is trimmed; quote it when significant;
- malformed input stops startup with a redacted location-aware error;
- a valid punctuation-bearing target is no longer rejected merely because a shell could not assign it with
NAME=valuesyntax.
This record describes a source commit, not a delivered release. Until the commit is pushed, tested remotely, merged, tagged, packaged, imaged, and deployed, operators must not assume a public SILO binary contains this parser contract.
Conclusion
Configuration compatibility depends on validating the format SILO actually consumes. MINIO_CONFIG_ENV_FILE borrows a small amount of dotenv-like syntax for operator convenience, but it is not executed by a shell. The repair restores named-target compatibility while retaining strict NUL, invisibility, redaction, and fail-fast guarantees.
4.6 - Two SSE-C Keys, One CopyObject Response
This record explains the CopyObject SSE-C checksum response repair committed in SILO as e37b0134a.
Status on 2026-08-28: implementation, encryption and key-rotation tests, complete server suites, race tests, static checks, build, and independent Fable Max acceptance review are complete. The commit exists locally; push, remote CI, merge, tag, package, image, deployment, and production verification remain separate gates.
Scope: the successful CopyObject XML and HTTP response after the destination object has committed. Stored object bytes, checksum metadata, encryption format, source decryption, federation, replication, and historical objects are unchanged.
Security property: source SSE-C headers may decrypt only source state; destination SSE-C headers may decrypt only committed destination state.
Too Long; Didn’t Read (TL;DR)
An SSE-C copy can use two independent keys:
| Role | Request headers | Purpose |
|---|---|---|
| source | X-Amz-Copy-Source-Server-Side-Encryption-Customer-* |
decrypt the source object |
| destination | X-Amz-Server-Side-Encryption-Customer-* |
encrypt and later interpret the committed destination object |
SILO correctly wrote the destination with its destination key. However, after commit, both the XML generator and the generic PUT-response header helper received the complete CopyObject request. The checksum metadata decrypter intentionally prefers copy-source SSE-C headers when they are present. That priority is correct while reading the source, but wrong when interpreting the committed destination.
With source key A and destination key B:
The object and stored checksum were correct; only the successful response was incomplete. The repair constructs a destination response-header view by removing exactly the three copy-source SSE-C customer headers. It decrypts the destination checksum once, then reuses the resulting map for both XML and HTTP response headers.
Observable failure
The failure requires a checksum-bearing destination and distinct source/destination SSE-C contexts. A representative request supplies:
Before the repair:
- CopyObject returned HTTP 200;
- reading the destination with key B returned the correct body;
- stored destination checksum metadata decrypted with key B and matched the logical bytes;
- the CopyObject XML and HTTP response omitted CRC32 and
ChecksumType.
This is a response-contract defect, not evidence of corrupted object data.
The same ambiguity affects same-object SSE-C key rotation. After metadata has been resealed under key B, the request still carries source key A in the copy-source headers. Response generation must describe the post-rotation object, so it must use B.
Why the global decrypter must not change
The metadata decrypter’s copy-source priority is not itself a bug. Earlier in CopyObject, the server examines source checksum metadata to decide whether to preserve its algorithm, recompute a full-object value, or add the default CRC64NVME checksum. For an SSE-C source, that metadata is protected by the source object key and therefore requires the copy-source headers.
Changing the global priority to prefer destination SSE-C headers would fix the final response while breaking source checksum interpretation. The safe boundary is temporal and object-specific:
The repair applies only at that post-commit boundary.
Selected implementation
Destination response view
The handler clones the request headers and removes exactly:
X-Amz-Copy-Source-Server-Side-Encryption-Customer-Algorithm;X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key;X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-MD5.
Regular destination SSE-C headers remain. SSE-S3 and SSE-KMS destination metadata needs no customer key and continues through the existing path.
Decrypt once, project twice
Before the repair, CopyObject called decryptChecksums once while building XML and again while writing success headers. For SSE-S3 or SSE-KMS this could repeat KMS unseal work.
The repaired flow is:
The generic setPutObjHeaders wrapper remains available to PutObject, CompleteMultipartUpload, and DeleteObject. CopyObject calls a narrow helper that accepts the already decrypted checksum map. ETag, VersionID, delete-marker, lifecycle prediction, and checksum header behavior remain in one shared implementation.
Regression matrix
The tests cover:
- plaintext source to SSE-C destination;
- compressed and uncompressed SSE-C destinations;
- SSE-C source key A to destination key B;
- checksum value and type in both CopyObject XML and HTTP headers;
- stored checksum decrypted with destination key B;
- destination body readable with B;
- same-object key rotation from A to B;
- checksum response after rotation;
- SSE-S3 source and destination combinations;
- all object-layer backends used by the API test harness.
The final combined tree passed focused encryption tests, the complete cmd and internal suites, the project’s tagged test configuration, full go test -race ./..., vet, lint, generated-file checks, rebrand guards, and a local build. A mirror Fable Max review reported no P0–P2 findings and independently confirmed that source decryption still receives the full request while destination response decryption receives the filtered view.
Compatibility and operational impact
- Successful CopyObject responses: checksum fields that were previously missing now appear when the committed destination has a checksum.
- Stored objects: no rewrite, migration, metadata-format, or encryption-format change.
- Existing objects: unaffected; the defect existed only in the one-time successful response.
- Clients: no request change. Clients already providing both source and destination SSE-C keys receive a more complete S3-compatible result.
- Performance: one metadata checksum decryption instead of two; no additional object read or hash pass.
- Rolling upgrade: old nodes may omit the fields while new nodes return them. Stored objects remain mutually readable.
- Rollback: restores response omission but does not damage objects created while the repair was present.
- Security: no key or digest value is added to logs or error responses. The response carries only the checksum already authorized for the successful write.
This repair does not resolve the separately deferred legacy federation CopyObject branch and does not audit or modify historical compressed-object checksums. Those questions have different data and operational boundaries.
Conclusion
CopyObject is one request with two object identities. Reusing the full request after commit erased that distinction: a source key was allowed to shadow the destination key while describing destination metadata. The durable repair is not a new encryption scheme; it is an explicit context boundary, followed by one decryption and two faithful response projections.
4.7 - Why CompleteMultipartUpload Must Return ChecksumType: Review of PR #57
This is the design, review, and decision record for SILO #47 and PR #57.
Status on 2026-08-26: PR #57 was approved and merged as
a96116b1; #47 closed automatically. All nine checks on the tested PR head passed, followed by green Go CI and VulnCheck runs onmain. No tagged release, package, container image, deployment, or production endpoint has yet been verified to contain the fix.
Scope: return the already-known checksum type fromCompleteMultipartUploadResult; do not add new checksum algorithms.
Owner:pgsty/silo, the SILO server repository.
Release boundary: code review, merge, a greenmain, a tagged release, packages, container images, deployment, and production verification are separate gates.
Too Long; Didn’t Read (TL;DR)
SILO already computed and persisted the correct checksum type for a completed multipart object. HEAD, ListParts, and GetObjectAttributes could expose it. The completion response could not, because its Go response struct had checksum value fields but no ChecksumType field.
PR #57 adds that field, copies the existing value from the checksum map, registers the new exported symbol in the compatibility baseline, and tests FULL_OBJECT, COMPOSITE, and the no-checksum case. It does not recalculate data, change metadata, migrate objects, or weaken integrity checks.
The repair is correct and intentionally narrow. Maintainers approved the fork workflows, refreshed the stale PR branch onto current main, required every new check to pass, submitted an approving review, and merged while preserving the contributor’s signed-off commit. Repository integration is complete; release delivery remains a separate gate.
Where the defect came from
The defect was found while investigating #31, where a real boto3 client exposed several adjacent multipart-checksum incompatibilities. #31 was the data-path failure: a FULL_OBJECT CRC32 multipart upload could fail at completion. It was fixed independently by 0cff48f6c and 75859690b, then closed on 2026-08-04. That review deliberately split four adjacent findings into #46, #47, #48, and #50 instead of treating them as one checksum bug.
After the object completed successfully, another inconsistency remained:
AWS S3 returned FULL_OBJECT in both places. SILO returned the checksum value in the completion XML, and the committed object retained the correct type, but the completion SDK result exposed a null type.
That observation became #47. It is a presentation defect, not a checksum-calculation or storage defect. It does not explain the earlier InvalidPart failure from #31, and repairing it does not replace the server-side part-checksum work tracked in #46, which later landed independently as 7fea6d5a5.
The S3 response contract
The AWS CompleteMultipartUpload API defines ChecksumType as an element of CompleteMultipartUploadResult. Its valid values are:
| Value | Meaning |
|---|---|
FULL_OBJECT |
The reported checksum covers the logical bytes of the completed object. |
COMPOSITE |
The object checksum is derived from the checksums of its multipart parts. |
When an object has no additional S3 checksum, the element should be absent. A server must not invent a type with no checksum value.
This distinction matters to clients. The same Base64 field name can describe either a direct full-object checksum or a multipart composition. A client that validates the completion result needs the type to interpret the checksum correctly and to compare the response with the mode selected at CreateMultipartUpload.
What SILO did before the PR
The completion handler already passed the committed ObjectInfo to generateCompleteMultipartUploadResponse. That generator already called:
The checksum decoder returned a map containing both the algorithm value and the normalized object type:
The response struct copied the values for CRC32, CRC32C, CRC64NVME, SHA1, and SHA256. It simply had nowhere to put the type:
Other surfaces used the same state correctly. ListParts and GetObjectAttributes already returned ChecksumType; HEAD also reported the stored type. The loss was isolated to the success XML for CompleteMultipartUpload.
What PR #57 changes
The contributed diff contains one signed-off commit, three files, 60 added lines, and no deletions. Only two production lines change. A maintainer later merged current main into the contributor branch to refresh its CI context; that merge changed history, not the three-file product diff.
Add the response field
omitempty is part of the compatibility contract: checksum-free uploads retain the old XML shape.
Copy the existing normalized value
The generator does not infer the type from an ETag, algorithm name, or part count. It uses the same decoded metadata that already supplies the checksum values.
Test the response surface
The added test covers:
- no checksum: the Go field is empty and
<ChecksumType>is absent; - a full-object checksum: the field is
FULL_OBJECTand the tag is present; - a multipart composite checksum: the field is
COMPOSITEand the tag is present.
It checks the response value before XML encoding and separately checks omission/presence after encoding.
Record the exported compatibility symbol
CompleteMultipartUploadResponse.ChecksumType is an exported Go field. SILO’s rebrand guard performs an exact comparison of the exported compatibility surface, so the PR correctly adds the field to buildscripts/rebrand-guard/compat-baseline.json. This is an acknowledgement of an intentional public surface change, not a bypass of the guard.
Why the repair works
The correctness argument is a short chain of existing invariants.
ObjectInfo.Checksumis the committed checksum metadata. The completion response is generated only after the object layer returns the committedObjectInfo.decryptChecksums(0, h)uses the existing metadata-decryption path, including the request headers needed for SSE-C. No second decryption mechanism is added.- The checksum decoder writes
x-amz-checksum-typeonly when it has decoded a non-empty checksum value. - Existing
ChecksumType.ObjType()logic normalizes reachable states toFULL_OBJECTorCOMPOSITE. - Indexing a nil or missing map entry returns the empty string.
- XML
omitemptyremoves the element for that empty string.
The resulting behavior is deterministic:
| Committed checksum state | Map value | Completion XML |
|---|---|---|
| No additional checksum | empty | no <ChecksumType> |
| Full-object checksum | FULL_OBJECT |
<ChecksumType>FULL_OBJECT</ChecksumType> |
| Multipart composite checksum | COMPOSITE |
<ChecksumType>COMPOSITE</ChecksumType> |
The change is therefore a missing projection from established state to the wire response. It does not create new checksum state and cannot make an incorrect checksum correct. It makes the response describe the state the server has already validated and committed.
Review and verification
The PR was reviewed after the contributor branch was refreshed onto current main. The update produced head c4b9d38d; the resulting tree hash, 39ec44c6b390c441413e490370f70fbacc4e6a91, exactly matched the isolated local no-commit merge. The result was clean and included the intervening checksum work on main.
Local verification on that exact merge result included:
The targeted regression completed in 2.174 seconds and the full cmd package test completed in 168.956 seconds. The commit author email matches its Signed-off-by trailer. Cryptographic Git commit signing is independent of DCO and is not required by this repository.
A separate read-only local Claude Code adversarial review inspected the merged diff, checksum serialization, XML path, current main, tests, DCO, and compatibility guard. Its verdict was COMMENT: the production change was correct and safe, but it preferred an additional HTTP-level completion test before merge. The maintainer agreed that such a test would improve fidelity, but disagreed that it was blocking: the handler delegates directly to the tested generator, while existing real MPU tests already cover persisted FULL_OBJECT and COMPOSITE states. The formal GitHub review therefore recorded APPROVED with the HTTP-level test as a follow-up.
Actions, branch refresh, and merge
The first four action_required runs had been created on 2026-08-09 against the PR’s old base. After approval, DCO passed but the old VulnCheck run used Go 1.26.5 and failed on newly published standard-library vulnerabilities fixed in Go 1.26.6. Current main had already moved to Go 1.27.0, and its latest VulnCheck was green. Treating the stale failure as either a product regression or an ignorable red check would both have been wrong.
The decision was to refresh the test context, not rerun or waive the stale result:
- GitHub’s update-branch API merged current
main(8d76a255c) into contributor headd014a12cf, producingc4b9d38dwithout conflicts. - GitHub created four new fork workflow runs for the refreshed head; all four were explicitly approved again.
- All nine reported checks passed: DCO, VulnCheck, six jobs in Go CI, and the Test Release Pipeline. The release validation job completed in 11 minutes 26 seconds.
- A formal approving review was submitted against
c4b9d38d. - Merge used an expected-head guard and the repository’s normal merge strategy, producing
a96116b1. This preserved the contributor’s signed-off commit rather than rewriting it through a squash. The PR’sResolves #47relationship closed the issue one second later. - The post-merge
mainVulnCheck and all six Go CI jobs also passed; cross-compilation, the slowest job, completed in 9 minutes 54 seconds.
This sequence matters because “the patch passed once” was not the acceptance criterion. The exact tree merged into current main had to be the tree reviewed and tested, and a stale CI environment could not substitute for that proof.
Evaluation of the PR
What is strong
- The scope matches the defect. Two production lines restore one missing response element.
- It reuses authoritative state. There is no duplicate type derivation and no new checksum algorithm branch.
- Backward compatibility is explicit.
omitemptypreserves checksum-free responses. - The test covers both valid values and absence. A regression cannot silently restore the null result.
- The compatibility baseline is updated deliberately. CI is not weakened.
- DCO provenance is complete. The sole commit has a matching sign-off.
Non-blocking review notes
The test is correct for the changed generator but its fixtures are not byte-for-byte models of every production multipart metadata flag:
- the
FULL_OBJECTfixture reaches the right value through a non-multipart checksum state rather than a completed multipart state carryingChecksumMultipart,ChecksumIncludesMultipart, andChecksumFullObject; - the
COMPOSITEfixture carries the multipart flag but omits the persisted per-part checksum block.
Existing API-level tests already exercise genuine FULL_OBJECT and COMPOSITE completion and verify their committed types. PR #57 tests the remaining projection from decoded state to the response field and XML. Adding an assertion to those full API tests would improve test fidelity, but it is not required for this two-line repair.
The PR places ChecksumType before the algorithm-specific fields, while AWS’s example response and SILO’s newer CopyObjectResponse place it after them. Mainstream S3 SDKs parse XML by element name, so this is a parity and style detail rather than a compatibility blocker. Moving the field is optional.
Finally, the contributor commit title says feat: even though the PR correctly marks itself as a bug fix. The final merge preserved that signed-off commit instead of rewriting it. This is a history/style imperfection, not a protocol or release blocker.
Why new algorithms do not belong in this PR
AWS now documents additional fields such as SHA512, MD5, and XXHASH variants. Adding those XML fields alone would create false compatibility.
SILO’s current checksum implementation supports CRC32, CRC32C, CRC64NVME, SHA1, and SHA256. A real new algorithm requires coordinated support across:
- request header parsing and validation;
- streaming checksum calculation;
- multipart
FULL_OBJECTorCOMPOSITEsemantics; - on-disk checksum encoding and decoding;
- UploadPart, UploadPartCopy, completion, copy, replication, HEAD, GET, ListParts, and GetObjectAttributes;
- SDK/client interoperability and a full encrypted/compressed/versioned test matrix.
PR #57 should not grow response-only placeholders for algorithms the server cannot calculate or persist. Each new algorithm family needs a separate compatibility decision, implementation, and review.
Compatibility and operational impact
- S3 clients: checksum-aware clients receive
ChecksumTypefrom future successful multipart completions instead of null. - Wire format: one additive XML element appears only when an additional checksum exists. Clients that ignore unknown elements remain unaffected.
- Integrity: no checksum is recalculated or accepted differently. Existing validation semantics are unchanged.
- Stored data: no object, part, metadata, or erasure format changes. No migration or backfill.
- Existing objects: object state remains correct. A past completion response cannot be replayed; use HEAD or GetObjectAttributes to inspect an existing object’s type.
- Encryption: the response uses the established checksum metadata-decryption path. No key material or new secret is exposed.
- Performance: one map lookup and one optional XML element; no extra object read, hashing pass, or allocation proportional to object size.
- Rolling upgrade: old nodes omit the element and new nodes return it. Requests and stored objects remain compatible, but client-visible behavior stabilizes only after all serving nodes are upgraded.
- Rollback: rolling back removes the response element from future completions; it does not damage objects created while the fix was present.
- Other repositories: no server dependency, silo-pkg, MCLI, or Console change is required. Public documentation belongs in this site.
This is an additive compatibility repair, not a release feature that requires operators to rewrite data. Its only externally visible effect is a more complete success response.
Merge and release decision
The final decision had six parts:
- accept the narrow projection fix without recalculating checksums or changing storage;
- keep SHA512, MD5, and XXHASH families out of #57 until they have end-to-end server support;
- record an HTTP-level completion test as useful follow-up work, not a blocker for the directly tested generator repair;
- reject stale CI as merge evidence, update the branch to current
main, and approve the newly created workflows; - merge only after the refreshed head was formally approved and every check was green, using an expected-head guard and a normal merge that preserved the DCO-signed contribution;
- let
Resolves #47close the issue, then verify the resultingmainworkflows independently.
No dependency update, storage migration, or cross-repository implementation was required. That decision is now complete at the repository-integration gate.
A green main still does not prove that a SILO tag, release package, container image, deployment, or production endpoint contains the repair. Those delivery gates remain unverified and must be recorded separately when the next release ships.
Conclusion
PR #57 is a good example of a small compatibility fix whose correctness comes from respecting an existing source of truth. The checksum type was already calculated, validated, persisted, decryptable, and visible through other APIs. The completion response simply failed to project it into XML.
The accepted repair does exactly that projection and nothing more. It makes the wire response honest without touching user data, checksum mathematics, storage layout, or algorithm scope. The fork workflows, refreshed-head review, merge, automatic issue closure, and post-merge main verification are complete. What remains is delivery discipline: distinguish this merged fix from a tagged, packaged, imaged, deployed, and production-verified release.
4.8 - When the Total Is Unknown: Folder Download Progress
Status: Implemented and verified locally; commit, Console release, and Silo dependency update pending · Priority: P1 · Owner:
pgsty/silo-console· Related issue:pgsty/silo#62· PRD review: Claude Fable 5 (xhigh) — APPROVE · Implementation review: Claude Fable 5 (xhigh), 2026-08-23 — APPROVE, no P0/P1/P2 findings
SILO Console shows NaN% in Downloads / Uploads while downloading a folder. The ZIP normally keeps streaming and the stored objects are intact, but the progress bar has crossed from “unknown” into an invalid determinate state. Users see a full-looking bar, assume the transfer failed or finished, and retry it.
The proposed repair is intentionally narrow:
A download may enter determinate mode only when it has a finite, positive total measured in bytes applicable to that response. Without such a total, it remains indeterminate until completion, failure, or cancellation.
The server keeps streaming ZIPs. Ordinary files keep their percentages. The frontend gains one safe calculation boundary, reuses its existing indeterminate renderer, and closes one missing cancellation transition. This record defines why that is both sufficient and the smallest truthful fix.
The observed failure
The defect is present in the current silo-console v2.1.1, which is embedded by Silo RELEASE.2026-08-06T00-00-00Z.
Reproduction:
- Put several objects below a prefix such as
folder/. - Stay in the parent listing, select
folder/, and click Download. - Open Downloads / Uploads before the transfer finishes.
- The row displays
NaN%; the ZIP request continues.
The runtime check used a prefix containing about 88.7 MiB and throttled Chromium to preserve the observation window. Two independent downloads produced the same NaN% state.
This is a frontend correctness bug. It is not evidence of corrupted objects, an altered disk format, or a failed S3 GET.
What is actually happening
The visible NaN% is the end of a contract mismatch across three layers.
A prefix has no object size
S3 folders are common prefixes, not stored directory objects. In the listing model, a prefix ends in / and carries size=0. The Console already renders that size as -, correctly treating it as not applicable.
The generated API model marks size as omitempty, so logical zeroes are absent from listing JSON. The single-selection thunk nevertheless passes object.size straight into the download helper: a prefix or zero-byte object therefore supplies undefined at runtime (while synthetic prefix records may supply 0). Neither value is a valid denominator.
A streamed ZIP has no known wire length
The server recognizes the trailing /, recursively lists the objects, then connects a zip.Writer to an io.Pipe. Objects are read, deflated, and copied to the HTTP response as the archive is produced.
That behavior is desirable: the server can send the first bytes without holding the complete archive in memory or on disk. Its consequence is equally deliberate: the final compressed byte length does not exist when headers are sent, so the response has Content-Type: application/zip and a filename, but no Content-Length.
The sum of source object sizes is not a substitute. Source sizes are uncompressed bytes; ProgressEvent.loaded counts response bytes after ZIP compression and framing. They are different units.
A progress event does not imply a computable percentage
The client currently computes every event as:
For a prefix, the denominator is zero or absent. Depending on the value and event, JavaScript produces NaN (loaded / undefined or 0 / 0) or Infinity (positive bytes divided by zero).
The progress callback then writes that non-finite value into Redux and sets waitingForFile=false. That second operation is the decisive state error: the task leaves the existing indeterminate branch merely because an event arrived, not because the event contained a usable total. The determinate progress component receives the invalid value and renders an invalid label.
The complete chain is:
Ordinary non-empty files avoid the defect because the server can stat the object, sets Content-Length, and the list size is positive. If the browser emits a progress event for an empty response, a zero-byte file reaches the same arithmetic boundary as a prefix even though it is a real object; it therefore belongs in the regression contract.
Product contract
The UI needs one honest distinction:
- Determinate means both transferred bytes and total bytes are known in the same unit.
- Indeterminate means the request is active but the total is unknown.
This yields four load-bearing invariants:
These invariants are more general than objectPath.endsWith("/"): they cover prefixes, zero-byte files, malformed metadata, and any future unknown-length response without inventing object-type exceptions.
Goals and non-goals
Goals
- A folder download never displays
NaN%,Infinity%, or a fabricated percentage. - Unknown-length transfers use the existing indeterminate animation.
- Known-length ordinary files retain their current percentage behavior.
- Completion, failure, and cancellation always leave indeterminate mode.
- A zero-byte file never produces a non-finite percentage and still reaches success.
- No non-finite or out-of-range download percentage enters Redux.
- The fix can ship in Console first and then be consumed by Silo as a dependency update.
Non-goals
- Do not pre-generate or buffer a complete ZIP on the server.
- Do not use the sum of uncompressed object sizes as network progress.
- Do not redesign the entire Object Manager state model.
- Do not route folders through the current immediately-completing
BrowserDownloadpath. - Do not solve the browser memory cost of
XMLHttpRequest.responseType="blob"here. - Do not change whether a cancelled row remains visible until the user clears it.
- Do not redesign mid-stream ZIP error signaling after HTTP headers have been sent.
- Do not modify the S3 API, Console API, object layout, or archive contents.
Those are legitimate follow-ups, but coupling them to this defect would enlarge risk without being necessary to restore truthful progress.
The decision
The minimum production repair has four parts.
D1. Calculate only from a valid total
Add a small pure function, separate from DOM and Redux side effects:
The source priority preserves compatibility:
- A finite positive
objectSizeretains the current ordinary-file calculation. - If object size is unavailable but the browser declares the response length computable and supplies a finite positive
event.total, use it. - Otherwise return
null: no truthful percentage exists yet.
The helper’s output contract is complete: either null, or a finite number in [0,100].
D2. Keep unknown totals indeterminate
Change the XHR handler to dispatch only a real percentage:
Download rows already start with waitingForFile=true, and ObjectHandled already renders that state with variant="indeterminate". There is no need to widen Redux to number | null, add another boolean, or change MDS.
When the first valid percentage arrives, the existing updateProgress action stores it and sets waitingForFile=false. When no valid percentage ever arrives, the row remains indeterminate until a terminal action.
D3. Make cancellation terminal
Completion and failure already clear waitingForFile. Cancellation does not. Add the missing transition in cancelObjectInList:
Without that line, the repaired prefix download would remain in the indeterminate rendering branch after abort, masking the Cancelled state. The row continues to follow the current product behavior: it remains as a cancelled record and can be removed manually. Automatic removal is not part of this change.
There is one event-order guard at the XHR boundary as well. abort() first produces readystatechange(DONE, status=0) and only then the abort event; without a status-zero return, the generic DONE branch marks the request failed before onabort can mark it cancelled. DONE/status zero is therefore left to the dedicated onerror or onabort handler, and onabort removes the stored request reference.
D4. Normalize an omitted zero-byte size
The single-selection thunk passes object.size || 0, matching the other download entry point. This restores the API model’s omitted logical zero before the helper checks Blob.size === fileSize, so an HTTP 200 zero-byte object completes at 100% instead of being reported as incomplete.
D5. Keep the server stream unchanged
The folder handler continues to generate a deflated ZIP through io.Pipe and omit Content-Length. No API, archive, storage, or resource-management contract changes.
State machine
| State | waitingForFile |
percentage |
Terminal flag | Rendering |
|---|---|---|---|---|
| Queued / no valid progress yet | true |
0 |
none | indeterminate |
| Unknown-total transfer | true |
0 |
none | indeterminate |
| Known-total transfer | false |
0..100 |
none | determinate percentage |
| Completed | false |
100 |
done=true |
success |
| Failed | false |
last value | failed=true, done=true |
error |
| Cancelled | false |
0 |
cancelled=true, done=true |
cancelled |
The state does not move back from determinate to indeterminate. If a later event lacks a valid total after a valid percentage was observed, the handler simply retains the last valid value.
Failed and Cancelled both set done=true in the existing reducers. ObjectHandled uses done to change its close button from “abort request” to “remove record”; this repair preserves that behavior. The cancelled Redux value remains 0, while the existing ProgressBarWrapper renders a full orange terminal bar with a Cancelled label because ready=true. That established presentation is not part of this repair.
waitingForFile is not the ideal long-term name for “no computable progress.” Renaming it or replacing the booleans with a discriminated union would improve the model, but that is a separate refactor. In this repair, the field already expresses and renders the required state, so reusing it minimizes compatibility risk.
Why this is sufficient
The repair closes the bug by cases.
Ordinary non-empty file
objectSize > 0, so the helper uses the same denominator as today. The result is finite and clamped, updateProgress enters determinate mode, and completion still sets 100%.
Current streamed folder
objectSize is normalized to 0, while lengthComputable=false and event.total=0. The helper returns null; no invalid action is dispatched, so the row remains indeterminate. Completion sets waitingForFile=false, percentage=100, and done=true.
Future response with a real length
If a proxy or later server implementation provides a trustworthy response total, lengthComputable=true and event.total>0. The same code automatically produces a real percentage without another product change.
Zero-byte file
The omitted listing size is normalized to zero, and both totals are then zero, so an intermediate percentage is mathematically undefined. The row stays indeterminate for its usually brief lifetime; the zero-byte Blob now equals the normalized expected size, and the successful response transitions directly to 100%. 0/0 is never evaluated.
Failure and cancellation
Failure already exits indeterminate. The added cancellation transition does the same on abort. No terminal row can continue to look active merely because its total was unknown.
Mathematically, division occurs only when total belongs to (0, +infinity). The result is then clamped to [0,100]. Therefore neither NaN nor Infinity can cross the calculation boundary into Redux or the determinate renderer.
Rejected alternatives
Buffer the ZIP to obtain Content-Length
The server could generate the complete archive in memory or a temporary file, measure it, and then send it. That would provide an exact wire total, but at the cost of memory or disk pressure, delayed first byte, cleanup complexity, and worse concurrent-download behavior. An observability defect does not justify discarding streaming.
Sum the objects under the prefix
That sum is uncompressed logical data. event.loaded measures compressed response bytes plus ZIP framing. The units differ, so the bar could stop below 100%, exceed 100%, or move according to compression ratio rather than transfer completion. Reject.
Convert invalid progress to 0%
This hides the string but lies about the state: determinate 0% means the total is known and no portion has transferred. Users would still interpret the transfer as stalled. Unknown must remain unknown.
Special-case paths ending in /
That fixes the reported prefix but misses a real zero-byte object, invalid metadata, and other unknown-length responses. The correct boundary is denominator capability, not object type.
Send folders through BrowserDownload
The current large-file path creates an anchor and immediately calls the completion callback after clicking it. It cannot report true completion, console-managed cancellation, or a subsequent HTTP failure. It may be the basis of a later streaming-download design, but today it would replace one lie with another.
Sanitize inside ProgressBar
A generic component guard could be useful defense in depth, but it would leave invalid data in Redux and hide the broken state transition from every other consumer. The primary repair belongs where progress becomes application state.
Introduce percentage: number | null now
A discriminated progress state would be cleaner than the current booleans if the Object Manager were being redesigned. Adding null while retaining waitingForFile, done, failed, and cancelled would instead create more contradictory combinations. Removing the old fields is larger than this bug requires. Reuse the already-rendered indeterminate state now; redesign it separately.
Requirements and acceptance
Functional requirements
- FR1: An unknown total keeps the task indeterminate.
- FR2: A finite positive object size preserves ordinary-file percentages.
- FR3: A finite positive
event.totalis a fallback only whenlengthComputable=true. - FR4: Every dispatched percentage is finite and within
[0,100]. - FR5: A zero-byte file never displays non-finite progress and reaches success.
- FR6: Completion, failure, and cancellation leave indeterminate mode.
- FR7: Versioned objects, anonymous downloads, previews, and long-filename entry points retain their existing call contract.
Non-functional requirements
- No new server CPU, memory, disk-buffer, or request cost.
- No new frontend dependency or build step.
- No change to the S3 API, Console API, ZIP content, or stored objects.
- The calculation must be testable without a DOM or live store.
- TypeScript typecheck and the production frontend build must pass.
Acceptance criteria
- While a folder ZIP without
Content-Lengthis active, its row shows an indeterminate animation and no percentage text. - On successful completion, the row reports success/100% and the ZIP can be opened.
- A normal non-empty file continues to show finite determinate progress and completes at 100%.
- A zero-byte file never shows
NaN%orInfinity%and completes successfully. - Cancelling an unknown-total download aborts the request and shows Cancelled, not an active animation.
- No download path can place a non-finite or out-of-range percentage in Redux.
Test plan
Pure calculation matrix
Use the existing @playwright/test runner for the pure module rather than adding a test framework. This needs one config-only addition in web-app/playwright.config.ts: a dependency-free unit project, for example with testMatch: /.*\.unit\.ts/. The existing chromium project depends on the auth setup against a live Console at localhost:9090; pure calculation and reducer tests must not be gated by that environment. No new dependency is introduced.
| Case | loaded |
objectSize |
lengthComputable |
event.total |
Expected |
|---|---|---|---|---|---|
| Ordinary file, halfway | 50 | 100 | false | 0 | 50 |
| Common prefix | 1024 | 0 | false | 0 | null |
| Initial zero over zero | 0 | 0 | false | 0 | null |
| Response-total fallback | 50 | 0 | true | 200 | 25 |
| Zero total is unusable | 0 | 0 | true | 0 | null |
| Loaded exceeds total | 150 | 100 | true | 100 | 100 |
| Invalid object size | 10 | NaN |
false | 0 | null |
| Omitted zero size | 10 | undefined |
false | 0 | null |
| Invalid response total | 10 | 0 | true | Infinity |
null |
| Negative loaded | -1 | 100 | true | 100 | null |
State tests
Cover the transition contract directly:
- A new download starts with
waitingForFile=true. - No valid progress action means it remains indeterminate.
- Valid progress produces a finite value and
waitingForFile=false. - Complete produces
done=true,waitingForFile=false,percentage=100. - Failure produces
failed=true,done=true,waitingForFile=false. - Cancel produces
cancelled=true,done=true,waitingForFile=false,percentage=0.
Browser regression
Use the real Console test instance and Chromium:
- Create a temporary bucket with several objects below
folder/. - Select the prefix from its parent and start the download.
- Apply CDP download throttling so the intermediate state is observable.
Throttled runs must raise the default 30-second test timeout with
test.setTimeout. - Open Downloads / Uploads and verify that the row exists, has no percentage label, and contains neither
NaN%norInfinity%. - Cancel it and verify the Cancelled terminal state.
- Restore network conditions in
finally. - Download again without throttling, wait for the browser download, and verify the ZIP.
- Repeat the relevant assertions for one ordinary non-empty file and one zero-byte file.
- Remove the bucket, objects, downloads, and temporary files in teardown.
The current Playwright project is Chromium-only, so CDP is an acceptable test mechanism. If Firefox or WebKit projects are later enabled, keep the pure and state tests cross-browser and gate only the throttled observation behind the Chromium project.
Implementation boundary
Expected Console changes:
- Add
downloadProgress.tscontaining the pure calculation. - Change
Objects/utils.tsto dispatch only a non-null percentage, let status-zero terminal events reach their dedicated handlers, and clean up an aborted request. - Normalize omitted zero sizes in the single-selection thunk.
- Change
cancelObjectInListto clearwaitingForFile. - Add calculation, state, and browser regression coverage using existing dependencies, with a dependency-free
unitproject inplaywright.config.ts.
Expected unchanged code and contracts:
- The Go folder-download handler and its streaming ZIP.
ObjectHandled,ProgressBarWrapper, and MDS.IFileItem.percentage: numberand the existing thunk callback types.- S3 and Console API routes.
- Stored object and archive formats.
Delivery and rollback
The fix belongs in pgsty/silo-console, not the Silo server repository where the issue was reported.
Delivery order:
- Transfer or cross-reference issue #62 to
pgsty/silo-console. - Implement the bounded Console change.
- Pass typecheck, production build, pure/state tests, and real browser regression.
- Publish a new Console release.
- Update Silo’s pinned Console pseudo-version or release dependency.
- Build a Silo candidate and repeat folder, ordinary-file, zero-byte, cancel, and ZIP-integrity checks.
- Publish Silo and record both affected and fixed versions on the issue.
There is no data migration. If the frontend change regresses, Silo can roll back only the Console dependency; server data and API behavior remain compatible.
Definition of done
- The calculation returns only
nullor a finite[0,100]number. - Active unknown-total folder downloads render indeterminate.
- Ordinary files retain determinate progress.
- Zero-byte files never render invalid progress.
- Complete, failed, and cancelled rows all leave indeterminate mode.
- The streamed ZIP and server response contract remain unchanged.
- Typecheck, production build, and automated regressions pass locally.
- A Console release is published.
- Silo updates the Console dependency and passes candidate verification.
Follow-up work
Four adjacent improvements deserve separate design records:
- Stream large folder downloads directly to the browser or filesystem instead of holding the full Blob in memory.
- Replace the Object Manager’s boolean combination with a discriminated progress/terminal state.
- Improve end-to-end integrity and error signaling for ZIP failures after headers have been sent.
- Add a generic non-finite-value guard to shared progress components as defense in depth.
- Repair the pre-existing Blob JSON error decoder and request-trace cleanup on HTTP failure paths.
None is required to stop the current UI from lying. The next maintenance iteration should first restore the smallest honest contract: known totals get percentages; unknown totals remain unknown.
4.9 - A ListObjects Shortcut Must Not Turn a Missing Bucket into an Empty One
This document records the problem analysis, design discussion, and repair decision for SILO #32 and PR #37.
Status on 2026-08-26: PR #37 was updated to the DCO-signed head
e9c5340be, formally approved, and merged as49c8aeac4; #32 closed automatically. DCO, VulnCheck, and all six Go CI jobs passed on the exact PR head; the post-mergemainVulnCheck and all six Go CI jobs also passed. No tagged release, package, container image, deployment, or production endpoint has yet been verified to contain the repair.
Scope: verify bucket existence only for three listing shortcuts that bypass storage; do not restore the genericcheckBucketExist, change the normal listing path, or introduce an existence cache.
Release boundary: local commit, push, remote CI, merge, tag, package, container image, deployment, and production verification are independent gates.
Too Long; Didn’t Read (TL;DR)
The problem is real and worth fixing. A normal ListObjects, ListObjectsV2, or ListObjectVersions request against a missing bucket reaches storage and receives BucketNotFound. Three inputs, however, return early:
- a marker outside the prefix;
max-keys=0;- a prefix beginning with
/, including thePrefix="/"boto3 reproduction from #32.
Those branches return io.EOF directly. The caller treats EOF as a successful end of listing, so the client receives an empty 200 rather than S3’s 404 NoSuchBucket. The identity of the same missing resource changes from an error to success solely because the selection parameters differ. That breaks S3 compatibility and blocks a real user’s upgrade from the pre-regression release.
The repair must not put an expensive bucket check back in every listing. The selected design replaces only the three bare io.EOF returns with a small helper. The helper calls GetBucketInfo once: it returns the real error if the bucket is absent or cannot be confirmed, and preserves io.EOF when the bucket exists. The normal listing hot path is untouched. Only requests that would otherwise exit before storage pay the extra peer-and-disk fan-out.
That decision has now been executed: the strengthened repair passed local review, the exact PR head passed every remote check, and the expected-head-guarded merge entered a green main.
What is the problem?
One API exposes two bucket-existence semantics
#32 reproduces the defect by calling the following against a missing bucket:
AWS S3 raises NoSuchBucket; SILO returns a successful empty listing. The difference is not in authentication, routing, or XML serialization. It comes from the object-layer listPath control flow:
/ is not the only trigger:
| Shortcut condition | Why the result must be empty | Defect before the repair |
|---|---|---|
| Marker does not begin with the prefix | The implementation does not scan this disjoint range | Returns EOF without confirming the bucket |
max-keys=0 |
The caller asks for zero keys | Incorrectly equates “zero results” with “valid resource” |
Prefix begins with / |
SILO’s flat key space produces no entries for this form | The filter short-circuits before bucket identity |
For an existing bucket, returning an empty listing from these branches is a reasonable optimization. For a missing bucket, the same EOF masks the resource error that should take precedence.
The regression has a known origin
The reporter confirmed correct behavior in RELEASE.2024-01-29T03-56-32Z and the regression beginning with RELEASE.2024-01-31T20-20-33Z. The corresponding upstream change is minio/minio#18917 / 80ca12008. It removed GetBucketInfo from generic argument checks and relied on actual Put, List, and Multipart storage operations to expose a missing bucket.
That optimization works on normal paths but leaves a gap: an early-return path never reaches the storage operation that is now responsible for producing the error. #32 does not require a broad rollback of the upstream optimization. It repairs the overlooked control-flow exits.
Why fix it?
The S3 contract explicitly requires NoSuchBucket
Both AWS ListObjects and ListObjectsV2 define NoSuchBucket as HTTP 404 when the specified bucket does not exist. prefix, marker, start-after, and max-keys select listing results; they must not turn a missing bucket identity into a successful request.
ListObjectVersions shares the same object-layer listing engine. Giving V1, V2, and version listings the same existence behavior on the same shortcut inputs prevents the three public APIs from diverging further.
An empty 200 changes client decisions
An empty 200 and a 404 are not interchangeable presentation details:
- 404 tells provisioning or test code to create the bucket, fix configuration, or stop;
- an empty 200 asserts that the bucket exists but has no matching objects;
- SDKs, synchronization tools, and integration tests continue down different branches;
- a test using SILO as an S3 substitute can pass locally and fail against AWS.
#32 also establishes a direct upgrade impact: an application relying on the older correct behavior cannot upgrade past the regression. The repair restores both S3 parity and upgrade compatibility.
The repair surface is narrow and testable
The bug is confined to three adjacent early returns. It does not involve object data, metadata formats, sorting, pagination-token encoding, permissions, or wire schemas. A very small production change can be pinned down with object-layer and HTTP-level contracts, so the benefit clearly exceeds the implementation risk.
Why not restore the global check?
Upstream did not remove generic GetBucketInfo as incidental cleanup. The motivation for #18917 states that checking the bucket before every Put, List, and Multipart operation fans out across servers; even after vectorization, the cost becomes visible beyond 100 nodes.
In current SILO, erasureServerPools.GetBucketInfo calls S3PeerSys.GetBucketInfo. That operation concurrently asks every peer and reduces quorum per pool, while each peer checks its local bucket state. It is not a cheap in-memory map lookup.
Two extremes are therefore unacceptable:
- never check: keep the incorrect empty 200;
- check before every List: restore semantics while undoing a critical large-cluster optimization.
The actual design question is whether the check can be confined to branches that never touch storage and therefore cannot discover the missing bucket naturally. It can.
How is it fixed?
Replace only three bare EOF returns
In cmd/metacache-server-pool.go, each shortcut previously executed:
It now executes:
The helper has only two classes of outcome:
- existing bucket: preserve the previous empty-list behavior;
- missing bucket: pass
BucketNotFoundinto the existing error mapping, producing HTTP 404NoSuchBucket; - state cannot be confirmed: propagate quorum, offline, timeout, or context errors instead of fabricating success.
The normal listMerged, metacache scan, sorting, pagination, and response-generation paths do not change.
Why the helper belongs here
The check must sit next to the shortcut for three reasons:
- only this layer knows that it is about to bypass every storage access;
- moving it into generic argument validation charges every call;
- moving it into the scan layer cannot help because these branches never scan.
The name intentionally states the boundary. This is not a new generic checkBucketExist; it restores missing existence semantics immediately before a shortcut returns EOF.
Do not add a cache
A bucket-existence cache could reduce fan-out but immediately creates invalidation questions for create, delete, site replication, recovery, and expiry. Adding a second source of truth for three low-frequency shortcuts costs more complexity and consistency risk than it saves.
The selected implementation uses the existing GetBucketInfo source of truth. If future telemetry shows that large clusters receive frequent max-keys=0, slash-prefix, or disjoint-marker probes, the project can evaluate a dedicated metadata fast path, rate limiting, or a carefully invalidated cache using real data rather than speculative machinery in this compatibility patch.
Test and review evidence
Object-layer contract
The object-layer test runs against single-drive and multi-drive erasure setups and exercises four inputs:
- slash-prefixed prefix;
- zero limit;
- marker outside prefix;
- a regular prefix as a control that still receives the error naturally from storage.
Each case covers ListObjects, ListObjectsV2, and ListObjectVersions, using the typed isErrBucketNotFound predicate rather than brittle English error-string comparison.
HTTP contract
The handler test sends genuine signed requests for all three public APIs:
| API | Request shape | Assertion |
|---|---|---|
| ListObjects | GET /missing-bucket?prefix=/ |
HTTP 404 and XML code NoSuchBucket |
| ListObjectsV2 | Add list-type=2 |
HTTP 404 and XML code NoSuchBucket |
| ListObjectVersions | Add versions |
HTTP 404 and XML code NoSuchBucket |
The HTTP test uses the real slash-prefix reproduction from #32. The other two shortcuts are enumerated at the object layer. This proves final wire behavior without repeating the full matrix in the slower handler fixture.
Local quality gates
The improved local commit passed:
The full local cmd test completed in 116.215 seconds. An independent local Claude Code review used the Fable model at Max effort to inspect the exact tree, call paths, error mapping, tests, performance boundary, and this decision. Its verdict was GO, with no mandatory pre-merge change.
The DCO-signed PR head e9c5340be then passed eight remote checks: DCO, VulnCheck, and six jobs in Go CI. After merge, the resulting main commit 49c8aeac4 independently passed VulnCheck and all six Go CI jobs. The slowest checks were PR cross-compile at 9 minutes 47 seconds and post-merge cross-compile at 9 minutes 30 seconds.
Can it introduce new problems?
Shortcut requests now fan out across the cluster
This is the most important and deliberately accepted cost. A shortcut on an existing bucket used to be little more than a local branch; it now calls GetBucketInfo. Directional local microbenchmarks observed:
| Path | Observed magnitude |
|---|---|
| Shortcut before the repair | about 0.55 μs, 7 allocations |
| Repaired single-drive shortcut | about 7.8–8.1 μs, 45–47 allocations |
| Repaired 32-drive shortcut | about 70–81 μs, 977 allocations |
| Normal 32-drive listing | about 0.95 ms |
These numbers show local relative cost only; they are not a latency prediction for a 100+ node deployment. Real distributed execution adds peer networks, quorum, and slowest-node tail latency, potentially making the gap much larger. That is precisely why the check must not expand into the normal listing path.
The risk concentrates in malformed or probe-style traffic. A misconfigured client polling max-keys=0, a slash prefix, or disjoint markers at high frequency can amplify what was a cheap request into peer-and-disk work. After merge, the actual frequency of these inputs should be observed through S3 traces or metrics; rate limiting or optimization should follow evidence.
A degraded cluster exposes more real errors
Previously, a shortcut could return an empty 200 while peers were offline or bucket quorum was unavailable because it never consulted cluster state. The repair can return quorum, timeout, or service errors in those conditions.
That is more honest behavior, not an availability regression: if the server cannot establish that the bucket exists, it must not assert a valid empty bucket. Clients depending on unconditional empty success will nevertheless observe a behavior change.
Bucket create/delete races are not linearizable
GetBucketInfo and returning the empty result are two actions. The bucket can be deleted immediately after the check, or created immediately after a missing-bucket result is formed. This patch does not and should not add a transaction spanning bucket lifecycle to a listing shortcut.
This is the same concurrency class as other APIs that validate a resource before acting. The repair guarantees that the request no longer succeeds with no existence evidence at all; it does not promise a cross-node, cross-lifecycle linearizable snapshot of an empty listing.
Clients relying on the bug will receive 404
Some clients may have adopted the missing bucket’s empty 200 as fact. They will now enter an error branch. This is a visible compatibility change, but it restores the documented S3 contract and the pre-regression behavior. Preserving the bug merely transfers upgrade cost to clients that correctly rely on 404.
Two adjacent edges remain out of scope
The adversarial review recorded two non-blocking P3 boundaries:
- When resuming a metacache continuation, the
c.fileNotFoundbranch still returns bareio.EOF. A stale or crafted continuation token used after bucket deletion could theoretically receive an empty 200. AddingGetBucketInfothere would affect normal continuation traffic and needs a separate performance and error-precedence design. - Some V1 and version-list marker/prefix combinations return
NotImplementedduring HTTP handler validation before reaching the object layer; the V2start-afterroute can reach it. This patch fixes storage shortcuts masking a missing bucket; it does not redefine precedence between malformed parameters and resource errors.
Neither blocks merge. The first is outside #32’s ordinary initial-list reproduction; the second is inherited handler behavior. Recording them prevents “all three shortcuts are covered” from being overstated as byte-for-byte AWS parity for every possible parameter combination.
Alternatives considered
Keep upstream behavior
This has zero performance change and minimizes fork divergence. It also keeps a documented S3 incompatibility, a regression with a known release boundary, and a misleading result when SILO is used as an integration-test substitute. For a narrow and well-tested compatibility repair, that tradeoff is no longer justified.
Restore generic checkBucketExist
This covers every path at once but reintroduces peer fan-out into every Put, List, and Multipart operation, directly undoing the large-cluster optimization from #18917. The cost is disproportionate and the option is rejected.
Fix only Prefix="/"
That passes the single issue reproduction but leaves the same root defect in max-keys=0 and marker-outside-prefix. The branches are adjacent and share the same semantics, so one helper is simpler and less likely to regress.
Add a bucket-existence cache
This makes shortcuts cheaper but requires semantics for create, delete, replication, recovery, and stale TTL windows. There is no telemetry showing enough shortcut traffic to justify that complexity, so it is not selected.
Complexity and cost-benefit
| Dimension | Assessment | Rationale |
|---|---|---|
| Production-code complexity | Low | Three call sites and a seven-line helper; no new state, dependency, or format |
| Test complexity | Low to medium | V1, V2, versions, three shortcuts, a control, and HTTP mapping all need coverage |
| Normal-path risk | Very low | No check is added to the listMerged hot path |
| Shortcut runtime cost | Materially higher | A local EOF becomes cluster-wide GetBucketInfo |
| Compatibility value | High | Restores 404 NoSuchBucket, pre-regression behavior, and S3 test fidelity |
| Operational complexity | Low | No migration, configuration, feature flag, cache, or cross-repository dependency |
The overall cost-benefit is favorable. The reason is not that GetBucketInfo is cheap—it is not—but that its cost is strictly limited to three shortcuts that otherwise cannot discover the missing bucket. A narrow performance cost in exchange for explicit protocol correctness is better than either a global rollback or indefinitely preserving the incorrect behavior.
Acceptance decision and remaining gates
The final decision was: accept and merge the strengthened PR #37 revision without expanding the production scope.
The accepted sequence was:
- replace the old fork head with the current-
main, DCO-signed revision while preserving Jason Lin as a co-author; - retain typed error predicates, V1/V2/version-list object-layer coverage, and HTTP-level 404 /
NoSuchBucketassertions; - update the PR description with the shortcut fan-out cost and unchanged normal-path boundary;
- approve the fork workflows and require all eight reported checks to pass on exact head
e9c5340be; - submit a formal approving review against that head;
- merge with an expected-head guard, producing
49c8aeac4, automatically close #32, and require the resultingmainGo CI and VulnCheck to pass independently.
No cache, feature flag, additional abstraction, or continuation-token redesign was required. High-frequency shortcut traffic and large-cluster tail latency remain observability follow-ups, not reasons for speculative code expansion.
Repository integration is complete. A tag, package, docker.io/pgsty/minio image, deployment, and real S3-client verification must still complete before the repair can be described as delivered to users.
Conclusion
The issue is not merely “a slash prefix reports the wrong error.” The listing engine uses io.EOF to mean two different things: an empty result from an existing bucket and an early exit that never established whether the bucket exists. Removing generic existence checks for large-cluster performance was a sound upstream optimization, but the shortcuts violate its premise that a real storage operation will naturally surface a missing bucket.
The selected repair restores that premise by calling the existing GetBucketInfo only at three storage-bypassing exits. It makes those requests more expensive and exposes real errors on degraded clusters; both are explicit costs. In return, SILO restores S3’s 404 semantics, upgrade compatibility, and test fidelity while preserving the upstream optimization on the normal listing hot path.
This worthwhile, controlled compatibility fix is now merged and green on main; release delivery remains a separate gate.
4.10 - Optional Checksums, Mandatory Failure: Repairing UploadPart and UploadPartCopy Compatibility
This is the complete design and implementation record for SILO #46. The repair was not merely a changed if statement. One apparently optional S3 header reached into multipart completion semantics, copy responses, compression and encryption pipelines, compatibility baselines, and release verification.
Status: server implementation and local verification complete; commit, PR, remote CI, release, and production verification pending.
Owner:pgsty/silo, the SILO server repository.
Tracking: #46.
Independent follow-ups: #63 CopyObject + compression checksum, #64 federated UploadPartCopy checksum.
Adversarial review: local Claude Code, Fable 5,--effort max; final verdict GO, with no blocking findings.
Too Long; Didn’t Read (TL;DR)
A multipart upload splits a large file into smaller parts. A client may attach a checksum to each part so the server can verify the transfer, but AWS defines that checksum as optional. SILO used to treat it as mandatory: an ordinary UploadPart failed without one, and UploadPartCopy could never work because it has no part-body checksum to provide.
After the repair, SILO still validates a checksum when the client sends one. When the client omits it, SILO computes the checksum while reading the original bytes and saves the result. This happens before compression and encryption, requires no second read, and changes no on-disk format. The result is AWS-compatible behavior without weakening data integrity.
Decision
When a multipart upload declares a checksum algorithm in CreateMultipartUpload, SILO applies this contract:
- If the client supplies a part checksum, the server continues to validate it. A wrong value or algorithm fails and is never hidden by fallback computation.
- If the client omits the part checksum, the server computes it in one pass with the MPU algorithm over the logical plaintext stream, before compression and encryption, and persists the result.
- A normal
UploadPartechoes a checksum response header only when the client supplied the checksum. A server-computed fallback is not echoed. UploadPartCopyhas no client part-body checksum, so the server computes the value and returns it inCopyPartResult.ListPartsreturns the persisted part checksum.FULL_OBJECTcompletion continues to linearize the full checksum from stored part checksums.COMPOSITEcompletion continues to require a checksum for every part; clients can recover those values withListParts.- Computation occurs during the existing read. Completion never re-reads the entire object merely to manufacture missing state.
In one sentence:
The optional input is the client-provided checksum value, not the server’s responsibility to maintain a consistent checksum-enabled MPU.
How we found it
The defect surfaced while investigating a different multipart checksum issue, #31.
#31 concerned CompleteMultipartUpload: for FULL_OBJECT, a client can complete with part numbers, ETags, and an optional full-object checksum without retaining every part checksum in the completion XML. Tracing that path backward exposed a stronger, earlier condition in erasureObjects.PutObjectPart:
Once an MPU declared a checksum algorithm, every UploadPart had to carry the matching x-amz-checksum-* value. Omitting it returned:
API-level probes reproduced the behavior on both the single-drive and erasure backends.
Reviewing CopyObjectPartHandler raised the severity from a client-configuration incompatibility to P0. UploadPartCopy has no request body for the caller to checksum. The handler reads the source object, constructs an internal reader, and eventually enters the same PutObjectPart implementation. There is no client header and no SDK setting that can repair the request. Every checksum-enabled MPU therefore rejected UploadPartCopy by construction.
What AWS requires
This cannot be decided by saying that MinIO has historically behaved a certain way. The S3 protocol is the authority.
The AWS UploadPart API describes each algorithm-specific checksum header as something that “can be used as a data integrity check.” More importantly, its response fields say that the checksum is present only when it was provided in the request.
The AWS UploadPartCopy API is different: when the MPU was created with an algorithm, the copy result contains that part checksum. There is no copy request body, so this is necessarily a server-computed value.
The AWS ListParts API is the standard way to recover checksums for parts in an upload that is still in progress.
The algorithm/type matrix also rules out treating the repair as one Boolean flag:
| Algorithm | FULL_OBJECT |
COMPOSITE |
|---|---|---|
| CRC64NVME | Supported | Unsupported |
| CRC32 / CRC32C | Supported | Supported |
| SHA1 / SHA256 | Unsupported | Supported |
FULL_OBJECT is limited to CRCs that can be linearized, but SHA1 and SHA256 still need correct per-part digests for COMPOSITE completion.
SDK configuration makes the gap practical. Current AWS SDKs usually calculate request checksums when an operation supports them, but users can choose request_checksum_calculation = when_required, and low-level callers can initiate an algorithm without repeating it on every part. S3 accepts those requests; SILO did not.
Why removing the check is not a fix
The most tempting patch is to delete the comparison and allow a checksum-less part to proceed. That only moves the failure to completion.
SILO does not reconstruct and re-read all object bytes during MPU completion. It reads ObjectPartInfo.Checksums from each part.N.meta:
- a missing entry immediately becomes
InvalidPart; FULL_OBJECTcallsChecksum.AddPart, combining digests with their part lengths;COMPOSITEconcatenates the raw digest bytes and hashes them into the object checksum.
The actual invariant is therefore:
Deleting the upload check without filling the metadata would make UploadPart appear successful, leave ListParts incomplete, omit the UploadPartCopy response value, and fail later during completion. A delayed failure is harder to diagnose than the original immediate one.
Alternatives considered
| Option | Benefit | Fatal problem | Decision |
|---|---|---|---|
| Delete the strict check | Smallest diff | Part metadata still lacks the checksum; completion must fail | Rejected |
Relax only FULL_OBJECT |
Unblocks some default CRC clients | Leaves COMPOSITE and SHA incompatible; cannot close #46 |
Rejected |
| Re-read every part at completion | Avoids storing a digest during upload | Adds O(object size) second-pass I/O and still cannot fix ListParts or the copy response |
Rejected |
Always return the server value from normal UploadPart |
Makes federation forwarding easy | Violates the AWS response contract | Rejected |
| Copy the AIStor implementation exactly | Commercial precedent | CRC-only fallback and a transformed-stream placement risk | Rejected |
| Compute and persist in one pass over logical plaintext | Complete protocol behavior, no second I/O, CRC and SHA support | Requires an explicit plaintext checksum reader distinct from the storage reader | Accepted |
What the commercial edition taught us
We downloaded and verified the then-current MinIO AIStor RELEASE.2026-08-07T18-34-35Z. Without a commercial license the server enters offline mode and denies S3 operations, so the evidence came from Go pclntab and ARM64 disassembly, not a black-box compatibility run.
The static analysis showed that AIStor already:
- installs a server hasher when the client checksum is absent;
- persists the result in part metadata;
- exposes checksum fields in
CopyPartResult.
It nevertheless applies fallback only to CanMerge() algorithms—CRC32, CRC32C, and CRC64NVME. SHA1/SHA256 COMPOSITE still follows the old checksum missing path. More importantly, the hasher is attached in the object layer to the current r.Reader; under compression or encryption that reader may already represent transformed storage bytes.
AIStor validated the general direction—compute and store—but not an implementation that SILO could copy mechanically.
How adversarial review overturned the first design
The first plan tried to centralize every decision inside erasureObjects.PutObjectPart: read the MPU metadata in the object layer and install a server hasher when the incoming reader had no client checksum. It looked attractive because all internal callers would share one rule.
The first Fable 5 Max adversarial review found that this design was wrong for compression.
newS2CompressReader is not a lazy wrapper. Construction immediately launches a goroutine:
The S2 writer also reads several blocks concurrently. After constructing the compressor, the handler still performs option parsing, encryption preparation, and the object-layer call. By the time PutObjectPart installed a hasher, the plaintext reader could already have lost several MiB:
- a large part would get a checksum with a missing prefix;
- a small part could reach EOF before installation and produce no result;
- mutating
ServerSideHasherconcurrently withReadwould be a data race.
That finding changed the responsibility split:
The handler installs the hasher before any eager transform starts; the object layer validates the algorithm, requires a result, and persists it atomically.
This was the decisive turn in the design. Putting logic in the lowest layer may look more uniform, but stream correctness depends equally on when bytes begin moving and which representation of those bytes a layer can see.
Final implementation
A dedicated logical checksum reader
PutObjReader originally distinguished two concepts:
Reader, the stream sent to storage, possibly compressed or encrypted;rawReader, used by older ETag and checksum code.
Under compression, even rawReader may not directly see plaintext; it can merely carry an ETag through an etag.Tagger chain. The repair therefore did not overload it. It added an unexported field:
This reader always represents the logical S3 part bytes. WithEncryption can replace the storage Reader, but it must preserve checksumReader.
Unexported accessors on PutObjReader then:
- return the effective client or server checksum type;
- prefer the client value whenever it exists;
- otherwise return the server result finalized at EOF.
Keeping the mechanism unexported minimizes public Go API growth and gives #63 a shared internal path without prematurely changing ordinary CopyObject behavior.
Preparing the hasher before transformations
prepareMultipartChecksumReader loads the algorithm and checksum type saved with the MPU:
- no declared algorithm means no work;
- an existing client checksum is compared by base algorithm;
- a wrong algorithm preserves the
InvalidArgumentrejection; - an omitted client checksum installs the corresponding server hasher on the plaintext reader.
For normal UploadPart:
- the compressed path prepares
actualReaderafter request-checksum parsing but beforenewS2CompressReader; - the uncompressed path prepares the request hash reader before the encryption reader is constructed.
For UploadPartCopy:
- a checksum-enabled MPU first gets an inner hash reader over the logical source range;
- a range copy hashes only the selected bytes;
- compression and destination encryption start only after that reader is ready.
The object layer remains authoritative
Early handler preparation does not replace the storage invariant. erasureObjects.PutObjectPart still:
- re-parses the expected MPU algorithm;
- requires an effective checksum type that matches;
- obtains the checksum map after erasure encoding finishes;
- reports an internal error instead of committing if an enabled algorithm has no result;
- writes the checksum with the ETag, sizes, and index into
part.N.meta, then atomically renames the part.
An internal caller that bypasses the HTTP handler without preparing a valid checksum is therefore rejected just as before. It cannot silently commit a part that violates the MPU invariant.
CopyPart response shape
CopyObjectPartResponse gained the five algorithms supported by this source tree:
All are omitempty, so an MPU without checksums produces the old XML. Normal UploadPart still uses the existing TransferChecksumHeader and echoes only a client request value; fallback computation does not alter that response.
Why it works
After the repair, the data flow is:
This satisfies four requirements that previously appeared to conflict:
- Protocol compatibility: omitting an optional header succeeds.
- No integrity downgrade: a supplied client value is still checked end to end and is never hidden by server fallback.
- Correct object semantics: the checksum covers logical S3 bytes, not compressed data or ciphertext.
- Controlled cost: hashing shares the existing read and adds CPU, not a second disk or network pass.
EOF has a precise role. hash.Reader finalizes ServerSideChecksumResult only when it reaches EOF. Closing the compression pipe synchronizes the compressor goroutine with the storage read; the object layer reads the result only after encoding returns. Targeted -race tests verified that concurrency boundary.
The compatibility-baseline blocker
The five new CopyObjectPartResponse fields are exported Go API. SILO’s buildscripts/rebrand-guard rescans imports, environment variables, headers, routes, storage markers, and exported symbols, then compares them in both directions with buildscripts/rebrand-guard/compat-baseline.json. An unacknowledged symbol makes CI fail.
After recording the five #46 fields, the guard still reported two additions:
They did not come from #46. They belong to the earlier database-notification repair f1ba68358 on the local main branch. The cmd startup path intentionally needs the exported type for errors.As, but that earlier commit had not updated the compatibility baseline. Every later change based on that HEAD would therefore fail the CI guard.
We chose “option A”: acknowledge the two notification symbols as part of their original repair while retaining the five #46 fields. The final baseline diff is exactly seven additions and zero deletions, and the guard reports:
This does not disable the check. Exact set equality means that acknowledging a nonexistent symbol also fails. The change explicitly records two intentional compatibility-surface additions.
golangci-lint has not yet run locally; it remains a remote go.yml gate. Green local go test, go vet, race, and rebrand-guard results do not substitute for green remote CI.
Verification evidence
The new tests execute 76 subtests across:
- CRC32, CRC32C, and CRC64NVME
FULL_OBJECT; - CRC32, SHA1, and SHA256
COMPOSITE; - correct client checksums, wrong algorithms, and wrong values;
- absence of a server-computed checksum in normal
UploadPartresponses; - server values in
UploadPartCopyresponses andListParts; - a real 5 MiB + 1 KiB two-part full-object merge;
- zero-length parts and overwriting the same part number;
- a range copy whose SHA256 covers only the copied interval;
- single-drive and 16-drive erasure backends;
- default, versioned, compressed, encrypted, and compressed-plus-encrypted modes;
- explicit SSE-C and SSE-S3.
Local validation included:
All passed. Two subsequent Claude Code Fable 5 Max implementation reviews and the final acceptance review returned GO with no blocking findings.
Cost, risk, and release boundary
When a client omits its value, the server performs one additional hash over the part. CRC cost is small; SHA costs more CPU. Both share the read that already had to occur, without buffering an entire part in memory or adding a completion-time second pass.
During a rolling upgrade, old and new nodes may answer the same checksum-less request differently: a new node accepts it while an old node returns 400. ObjectPartInfo.Checksums did not change format, so stored data remains downgrade-readable, but client-visible behavior stabilizes only after all serving nodes have upgraded. The release note must call that out.
This record describes a local main worktree. The implementation has not been committed, pushed, run through remote CI, or packaged into a release. SILO documentation belongs to silo.pgsty.com; a successful local Hugo build does not mean that the product in the wider pgsty.com ecosystem has shipped.
Why two follow-ups remain separate
Adversarial review found two related but independent issues.
#63: CopyObject + compression
Ordinary CopyObject can also attach a server-side checksum to a transformed stream. It shares the root cause and the new checksumReader mechanism, but it is a different API with a different test matrix and rollback boundary. We chose a separate repair and require that PR to reuse this plaintext-reader contract instead of inventing a second abstraction.
#64: legacy federation
Legacy etcd federation turns UploadPartCopy into an ordinary remote UploadPart. Under the AWS response semantics preserved here, that remote request does not return a server fallback value, so the proxy may still lack the checksum required for CopyPartResult. A follow-up must independently choose between a remote-returned value and an ETag-verified ListParts fallback. It must not make all external UploadPart responses non-compliant merely to simplify an internal proxy.
Separating them does not abandon consistency. Consistency is maintained through one shared rule:
Every server-computed S3 checksum binds to the logical plaintext stream, is installed before any eager transform, and is validated and persisted by the object layer that owns the storage invariant.
Lessons retained
The repair leaves lessons more durable than its individual lines of code:
- An optional header does not make internal state optional. If the protocol lets the client omit a value, the server must produce the state its own completion path needs.
- Request acceptance and response disclosure are separate contracts. A normal UploadPart may compute internally and still omit the value; UploadPartCopy must return it.
- Stream layers are defined by byte semantics. The lowest layer is not automatically correct if it no longer sees logical bytes, and an eager goroutine turns “install later” into a race.
- A commercial implementation is evidence, not the specification. AIStor showed the direction and the boundary that could not be copied.
- A compatibility guard is a change-acknowledgment mechanism.
compat-baseline.jsonexists to assign every new compatibility surface, not merely to make CI quiet. - Independent defects should ship independently while sharing invariants. #63 and #64 remain separate, but both must cite and obey the checksum-reader contract established here.
The final result is not a broad relaxation. It is a stricter and more accurate boundary: clients may omit optional information; the server may not omit correctness.
4.11 - BadDigest, InvalidRequest, and the CompleteMultipartUpload Checksum Contract
This is the design, investigation, and verification record for SILO #48, with the decision boundary for the related SILO #50.
Status:
pgsty/silo#74merged as590aeaa7d, andpgsty/silo.pgsty.com#6merged as9805dd7; full local verification, remote CI, and independent Opus 5 Max acceptance review completed on the linked changes. Tag, release, package, image, deployment, and production verification remain separate pending gates.
2026-08-28 follow-up: signed-off server commitf7bc725d8closes the remaining type-only and invalid-token bypass without changing CRC64NVME canonicalization. Complete local, tagged, race, static, build, and Fable Max verification passed; push, remote CI, merge, tag, and delivery remain pending.
Owner:pgsty/silo, the SILO server repository.
Implementation scope:CompleteMultipartUploaderror semantics only; no storage-format, checksum-math, dependency, Console, package, or client change.
Independent decision: #50 remains probe-gated and is not part of this repair.
Too Long; Didn’t Read (TL;DR)
Issue #48 is valid and should be fixed, with two corrections to the original report.
First, the checksum-type comparison is worse than the issue states. SILO used bitmask containment instead of equality. An upload created as FULL_OBJECT and completed as COMPOSITE failed, but the reverse COMPOSITE to FULL_OBJECT direction could pass the type check. The repair must compare the base algorithm and normalized multipart object type independently and symmetrically.
Second, the missing-part-checksum row originally lacked a direct AWS capture. That evidence now exists in the official boto/s3transfer project: issue #241 records a real S3 InvalidRequest response naming sha256 and missing part 1, and PR #242 repaired the client and added tests. This is strong enough to implement the response contract without a new AWS account probe.
The accepted behavior is:
CompleteMultipartUpload failure |
SILO before | Required behavior |
|---|---|---|
| Supplied object checksum does not match the assembled object | XAmzContentChecksumMismatch |
BadDigest |
| Completion checksum type differs from initiation, in either direction | one direction InvalidArgument; reverse direction could pass |
BadDigest |
| Completion declares a different type but sends no whole-object checksum | type assertion ignored | BadDigest |
| Completion sends an unknown non-empty type, with or without a checksum value | could be ignored or interpreted through checksum defaults | InvalidArgument |
| A composite completion omits a checksum for a part | InvalidPart |
InvalidRequest, naming the algorithm and part |
The repair uses completion-specific error types. It deliberately does not change the global mapping of hash.ChecksumMismatch, so PutObject, UploadPart, streaming trailers, and other operations retain their existing XAmzContentChecksumMismatch contract.
Issue #50 is a separate question. AWS documents that CRC64NVME is full-object only, but the available sources do not prove that S3 rejects an explicit CRC64NVME + COMPOSITE initiation instead of canonicalizing it. Upstream MinIO intentionally implemented canonicalization and exposes FULL_OBJECT in the initiation response, so the behavior is not silent. A raw AWS probe is required before changing it.
Scope and decision
This record answers two different questions:
- Are the #48 error-code deviations real, externally observable compatibility defects with enough evidence to repair?
- Does the same evidence authorize changing the CRC64NVME canonicalization described by #50?
The decisions are:
- #48: accept with corrections and implement. The error codes are part of the S3 wire contract. Returning a different code makes SDK behavior and operator diagnosis diverge even when the request is rejected in both systems.
- #50: do not implement yet. The capability matrix proves the resulting checksum must be full-object. It does not establish whether an invalid requested type is rejected, ignored, or canonicalized. Those are different wire contracts.
The repair is intentionally narrow. It does not add algorithms, recalculate stored data, reinterpret successful uploads, or change the optionality rules repaired for #31 and #46.
Evidence ledger
Not all evidence has the same authority. The implementation decision uses the following hierarchy.
| Grade | Source | What it establishes | Limitation |
|---|---|---|---|
| A | AWS checksum upload guide | A supplied full-object checksum mismatch fails with BadDigest; algorithm/type capability matrix |
Does not show every response message |
| A | AWS CompleteMultipartUpload API and AWS CLI reference | A completion checksum type that differs from initiation fails with BadDigest |
Does not publish the exact message text |
| B+ | boto/s3transfer #241 | Real AWS S3 transcript: missing SHA256 checksum for part 1 returns InvalidRequest and names the algorithm and part |
Captured in an official SDK project issue rather than an AWS API reference page |
| B+ | boto/s3transfer #242 and the 0.6.1 changelog | The official transfer client was changed to forward UploadPartCopy checksums into completion; functional coverage prevents recurrence | Primarily client-side evidence |
| B | Local API probes and regression tests | SILO’s old XAmzContentChecksumMismatch, InvalidArgument, InvalidPart, and reverse-direction bypass are reproducible on both object-layer backends |
Establishes SILO, not AWS |
| C | Upstream MinIO history | Explains how the current behavior entered the lineage and why it remains | Intent is not proof of AWS parity |
This distinction matters. The original #48 comment correctly downgraded the third row while it was supported only by secondary reports. The boto transcript and the merged client repair close that evidence gap.
The observable contract
Object checksum mismatch
For a FULL_OBJECT multipart upload, SILO combines stored part checksums and compares the result with the optional object checksum supplied on completion. The old code returned hash.ChecksumMismatch. A global API mapping converted that type to:
AWS explicitly documents BadDigest for the corresponding completion integrity failure. Reusing the existing generic ErrBadDigest code without a custom message would still be misleading because its static text says Content-MD5; CRC32, CRC32C, and CRC64NVME are not Content-MD5.
The new response is therefore operation-specific:
The response does not disclose the expected or supplied digest.
Checksum type mismatch
The checksum type saved by CreateMultipartUpload is part of the upload’s contract. A completion may not switch between COMPOSITE and FULL_OBJECT.
The old test was:
ChecksumType.Is is a containment operation over a bitmask, not equality. For CRC32:
The second request could proceed using the persisted composite rules. If the caller supplied the composite checksum value under a FULL_OBJECT declaration, completion could even succeed. This is a protocol validation bypass, not merely the wrong error label.
The repair normalizes both values into multipart checksum types, then compares:
- base algorithm equality; and
- object type equality (
COMPOSITEversusFULL_OBJECT).
For algorithms whose two object-type forms are both syntactically accepted—currently CRC32 and CRC32C—both mismatch directions now return 400 BadDigest. SHA1 and SHA256 with FULL_OBJECT are rejected earlier as InvalidArgument; CRC64NVME is the canonicalized special case discussed under #50 below. Base-algorithm mismatch remains a separate InvalidArgument path because #48 and the cited AWS type contract do not authorize broadening that behavior.
Type-only assertions and invalid tokens
The first #48 repair remembered whether x-amz-checksum-type was present, but its object-layer comparison was still nested under WantChecksum != nil. WantChecksum is populated only when completion carries a checksum value. A caller could therefore send a type assertion without a whole-object checksum:
The server returned success and persisted the initiated composite state. It did not corrupt the object, but it accepted an explicit integrity assertion that contradicted the upload contract.
There was a second parser asymmetry. In the header-without-algorithm path used by completion, an unknown value such as NOT_A_TYPE could be ignored when a checksum header was also present. Relying on ChecksumType.ObjType() after creating an invalid bitmask would not be safe: an invalid non-multipart value can fall through to the full-object default. Raw enum validation must happen first.
The follow-up stores the explicit raw type string in ObjectOptions, accepts only COMPOSITE or FULL_OBJECT, and compares it with the initiated multipart type independently of WantChecksum. The order is deliberate:
- reject every unknown non-empty token as
InvalidArgument; - compare the base algorithm when a checksum value is supplied;
- compare the explicit object type whenever the upload recorded a checksum algorithm;
- report an explicit type mismatch as
BadDigesteven when no object checksum value was supplied.
CRC64NVME remains a deliberate exception. A raw COMPOSITE token is normalized to FULL_OBJECT before comparison, preserving the inherited behavior pending the #50 AWS probe. A legal type-only header on an upload that recorded no checksum algorithm remains outside the comparison because there is no initiated checksum type to assert against; its exact AWS error semantics remain unproven and were not expanded into this repair.
Missing composite part checksum
For a composite upload, the completion XML must include the selected checksum for every listed part. SILO previously compared an empty client value with the stored part checksum and returned InvalidPart.
That conflated three different states:
- the part or ETag does not exist;
- a checksum was supplied but has the wrong value or algorithm;
- the required checksum element is absent.
The third state now has a dedicated error. Its wire message follows the AWS response captured by boto/s3transfer:
The error is emitted for the first missing part and includes its actual number. FULL_OBJECT behavior is unchanged: a completion may omit per-part checksum elements, while any supplied part checksum must still be valid.
Root cause in the upstream lineage
The behavior is inherited rather than a SILO-specific redesign.
- MinIO PR #15433 introduced extended checksum handling and the global
hash.ChecksumMismatchtoXAmzContentChecksumMismatchmapping. That mapping is suitable for streaming upload validation but too broad for completion semantics. - MinIO PR #20855 added full-object checksums and CRC64NVME. It introduced the checksum-type comparison and intentionally canonicalized CRC64NVME to full-object with the comment that AWS appears to ignore the supplied mode.
- MinIO PR #20953 tightened invalid algorithm/type combinations but retained the CRC64NVME special case. That is evidence of deliberate upstream behavior, not an accidental missing branch.
- MinIO issue #20944 reported an AWS
BadDigestversus MinIOInvalidPartdifference. The divergence was acknowledged but not repaired.
The upstream repository is now archived. SILO therefore owns the compatibility decision, tests, and maintenance burden rather than waiting for an upstream correction.
Repair design
Operation-scoped errors
Changing the global hash.ChecksumMismatch mapping would alter every operation that uses it. That would be a larger, weakly evidenced compatibility change.
The repair adds three package-private, sentinel-backed error helpers in the server command package. Keeping the helpers and the request-header-presence flag private avoids expanding SILO’s exported Go compatibility surface:
completeMultipartChecksumMismatch, mapped toBadDigestwith a checksum-aware description;completeMultipartChecksumTypeMismatch, mapped toBadDigestwith provided and initiated types;missingPartChecksum, mapped toInvalidRequestwith algorithm and part number.
Only CompleteMultipartUpload produces these types. The global mapping remains:
This preserves PutObject and UploadPart behavior and makes the compatibility boundary visible in code.
Symmetric type validation
Both persisted and supplied types are normalized with the multipart flags before comparison. This is necessary because a bare CRC checksum type describes a non-multipart full-object checksum through ObjType(), while the same base value means composite after multipart context is applied. Object type is compared only when the completion request explicitly contains x-amz-checksum-type; omitting an optional header does not synthesize a COMPOSITE assertion.
The resulting invariant is:
The second condition applies only to an explicitly supplied type. This comparison is symmetric and remains compatible with the existing CRC64NVME canonicalization. It fixes #48 without silently deciding #50.
Precise missing-value detection
For each part, the server already builds a map of all checksum fields supplied in the completion XML. The repair distinguishes:
This is intentionally narrower than converting every part checksum failure to InvalidRequest. Only the state demonstrated by AWS evidence changes.
The same edit corrects the internal InvalidPart expected/actual field order. The generic S3 InvalidPart wire response did not expose those digest values, but internal error text and logs should still describe them correctly.
Regression and detection matrix
The API-level tests exercise signed HTTP requests through both the single-drive and erasure object-layer backends.
| Test | Request | Required assertion |
|---|---|---|
| Full-object digest mismatch | Correct parts, wrong object CRC32 | HTTP 400, BadDigest, checksum-aware message, no object committed |
| Composite object digest mismatch | Correct CRC32 part values, wrong composite object value | HTTP 400, BadDigest; covers the separate checksum-of-checksums path |
| Type mismatch: full to composite | Initiate CRC32 FULL_OBJECT, complete COMPOSITE |
HTTP 400, BadDigest, provided/expected types named |
| Type mismatch: composite to full | Initiate CRC32 COMPOSITE, complete FULL_OBJECT |
HTTP 400, BadDigest; closes old containment bypass |
| Type-only mismatch in both directions | Initiate one CRC32 type; complete with the opposite type and no object checksum value | HTTP 400, BadDigest; the explicit assertion cannot bypass validation by omitting the digest |
| Invalid explicit type | Complete with NOT_A_TYPE or lowercase full_object, with and without a checksum value |
HTTP 400, InvalidArgument, no object committed |
| Matching type-only assertion | Initiate and complete CRC32 COMPOSITE, omit object checksum value |
Success; the valid assertion is enforced without inventing a required digest |
| Omitted optional type | Initiate FULL_OBJECT, complete with checksum value but no type header |
Success; omission is not treated as explicit COMPOSITE |
| Algorithm mismatch guard | Initiate CRC32, complete with CRC32C | Still InvalidArgument |
| CRC64NVME #50 guard | Initiate CRC64NVME with explicit COMPOSITE, then complete with explicit COMPOSITE |
Still succeeds through existing full-object canonicalization; records the completion-side residue rather than claiming #48 validates the raw type token |
| Missing composite checksum | CRC32 and SHA256 composite uploads; omit all values, then omit only part 2 | HTTP 400, InvalidRequest, lowercase algorithm and actual missing part named |
| Global-mapping guard | Direct hash.ChecksumMismatch mapping |
Still XAmzContentChecksumMismatch |
| UploadPart guard | Wrong client part checksum | Still XAmzContentChecksumMismatch |
The committed type-mismatch regression uses CRC32, while an independent acceptance probe covered CRC32C as well. The follow-up additionally covers type-only, unknown, lowercase, matching, and checksum-bearing invalid-token cases. The same matrix confirms that SHA1/SHA256 FULL_OBJECT requests stop earlier at the existing invalid-combination check and that CRC64NVME still canonicalizes an explicit COMPOSITE token. Those distinctions are protocol boundaries, not untested claims that every algorithm reaches the same error mapper.
Focused verification command:
Observed result on 2026-08-27:
The complete local package gate was then rerun after the review-driven additions:
git diff --check also passed. Independent review of the final diff remains a separate gate. A local pass is not remote CI, a merged commit is not a release, and a release is not production deployment.
Independent adversarial review
The first review of the actual server diff was performed with local Claude Code in read-only safe mode. Its verdict was GO with no blocking findings. It independently confirmed the operation-scoped mapping, symmetric bitmask normalization, per-part missing-value detection, both object-layer backends, and preservation of UploadPart behavior.
The review identified four useful gaps that were incorporated before the second full test run:
- distinguish an omitted optional type header from an explicit
COMPOSITEassertion; - separate value-mismatch and type-mismatch error types;
- exercise the composite checksum-of-checksums mismatch path;
- pin missing part 2, algorithm mismatch, and unchanged CRC64NVME canonicalization.
One first-review concern was rejected by primary evidence: it questioned whether checksum type mismatch should return InvalidRequest. The AWS CompleteMultipartUpload reference and AWS CLI reference explicitly specify BadDigest when the completion type differs from initiation.
The final first-round re-review verdict was FINAL GO, no blockers. It explicitly withdrew the earlier error-code concern, agreed with accepting #48 and deferring #50, verified that the new guards preserve the intended non-changes, and found no English/Chinese drift.
A subsequent independent acceptance run used Claude Code claude-opus-5 with maximum effort. It returned ACCEPT, no blocking findings, reproduced the old composite-as-FULL_OBJECT bypass end to end against the pre-fix code, verified that the new API assertions fail against that code, and probed all five checksum algorithms in both type directions.
The 2026-08-28 follow-up received a separate local Fable Max mirror review over the complete uncommitted release-review diff. It returned GO, with no P0–P2 findings. The primary review independently checked its seven P3 observations: five were non-blocking boundaries, while two proposed causes were disproved by the actual config and key-rotation call paths. The review confirmed that raw invalid types are rejected before normalization, type-only mismatch is enforced, source-side checksum decryption still receives the full request, and CRC64NVME canonicalization remains untouched.
Five pre-existing or deliberately deferred, non-blocking observations remain outside these repairs:
- SHA1/SHA256
FULL_OBJECTcombinations are rejected by the existing parser asInvalidArgumentbefore the new type-mismatch mapper; only CRC32/CRC32C reach both mismatch directions; - CRC64NVME treats any type value as full-object state, so completion with an explicit
COMPOSITEtoken is still accepted through canonicalization pending the #50 AWS probe; - when initiation recorded no checksum algorithm but completion supplies an object checksum, SILO returns
BadDigest; AWS documentation says such a value is accepted and ignored, so this should be triaged as a separate compatibility issue; - composite part-count and value mismatches both become
BadDigestwith the same description; - a full-object checksum carrying a
-Nsuffix has that suffix ignored while its digest is still validated.
None is introduced by these patches, and none changes the #48 decision. They should be triaged separately if strict message or invalid-header parity becomes a maintenance priority.
Why #50 is not included
Issue #50 says CRC64NVME + COMPOSITE should be rejected at initiation. Three facts are confirmed:
- AWS’s algorithm matrix supports CRC64NVME only as a full-object checksum.
- SILO and upstream MinIO canonicalize the request to full-object state.
- The server returns
x-amz-checksum-type: FULL_OBJECTfromCreateMultipartUpload, so the substitution is externally visible rather than silent.
What is not confirmed is the decisive wire behavior: does AWS reject the explicit invalid combination, or accept it and return/carry full-object state? A capability matrix does not answer that question.
The upstream history also argues against guessing. PR #20855 added the canonicalization intentionally, and PR #20953 preserved it while tightening other invalid combinations. That may be based on an AWS observation, but the comment is not a reproducible transcript.
The same representation also affects completion: FullObjectRequested treats every CRC64NVME checksum as full-object state, so a stored FULL_OBJECT upload completed with the raw header value COMPOSITE is accepted as full-object rather than rejected as a type mismatch. This completion-side residue falls under the same raw-token-versus-canonical-state evidence question. It is explicitly not claimed fixed by #48.
PutObject must not be bundled into this decision. Its API reference does not define x-amz-checksum-type, so accepting, rejecting, or ignoring that header is a separate undocumented-header question.
Required AWS probe
Before changing #50, capture a raw SigV4 request and response against a general-purpose AWS S3 bucket:
- send
CreateMultipartUploadwithx-amz-checksum-algorithm: CRC64NVMEandx-amz-checksum-type: COMPOSITE; - record the HTTP status, error code/message, request ID, and all checksum response headers;
- if accepted, upload one part and complete it, recording whether S3 requires per-part values and which type
HeadObjectreports; - repeat with
FULL_OBJECTas the control; - probe
PutObjectseparately, explicitly labeling it as an undocumented-header experiment.
Only a captured rejection authorizes replacing canonicalization with validation. If AWS accepts and canonicalizes, #50 should be corrected or closed rather than implemented.
Compatibility and operational impact
- Successful requests: checksum semantics are unchanged, except that omitting the optional
x-amz-checksum-typeheader is no longer misclassified as an explicitCOMPOSITEassertion. That intentional interoperability relaxation changes the old erroneous 400 into success. - Rejected requests: apart from that omitted-header case, HTTP status remains 400; the affected S3 error code and message become AWS-compatible. Explicit type-only mismatch is now enforced, and an unknown non-empty type is rejected as
InvalidArgumentbefore bitmask normalization. - Integrity: unchanged or stronger. The reverse type-bypass is closed; no failed completion commits an object.
- Stored data: no format, checksum encoding, metadata, erasure layout, migration, or backfill change.
- Performance: constant-time comparisons and error construction only; no additional data reads or hashing passes.
- Security/privacy: digest values are not returned in the new messages. Bucket and object names are not added to them.
- Rolling upgrade: nodes may return different error codes until all serving nodes are upgraded, but successful objects remain compatible.
- Rollback: restores the old error codes and asymmetric check; it does not require data rollback.
- Other repositories: no Console, shared-package, MCLI, or SDK change is required. This public design record is the only cross-repository deliverable.
Merge and release gates
| Gate | Base #48 repair | 2026-08-28 follow-up |
|---|---|---|
| Design and local verification | complete | complete |
| Independent adversarial review | complete, ACCEPT | complete, GO |
| Signed-off server commit | complete | local f7bc725d8 |
| Push, remote CI, and merge | merged as 590aeaa7d |
not established |
| Public design record | merged as 9805dd7 |
this documentation update is local |
| Tag and release artifacts | not established | not established |
| Container image and package | not established | not established |
| Deployment and production probe | not established | not established |
The follow-up must keep #50 out unless a raw AWS transcript changes the decision, run remote DCO/Go CI/vulnerability/release-pipeline checks on its final commit, and merge from the current SILO main. Repository integration, release artifact, image, deployment, and production probe remain independent gates; none can be inferred from a local test or documentation build.
Conclusion
#48 is a correct compatibility issue, and the evidence now covers all three rows. The safest repair does not relabel checksum failures globally. It teaches CompleteMultipartUpload to report its own protocol errors, compares checksum types symmetrically, and identifies a genuinely missing composite part checksum without confusing it with a missing part or a wrong value.
#50 is related by discovery history, not by proof. The server’s current CRC64NVME canonicalization is deliberate and visible. Until AWS’s exact response is captured, changing it would replace one unverified assumption with another.
That boundary is the central design decision: implement what the official contract and tests establish, test the hidden consequence found in the code, and leave the remaining policy question behind an explicit, reproducible evidence gate.
4.12 - Per-Bucket CORS: Making Deletes and Recovery Converge
This document records the problem, review, merge decision, adversarial debate, and final implementation contract for SILO PR #71 and the release-hardening work tracked by SILO #75.
Status: The complete B2+B3 server solution merged through PR #80 as
b6ef7e430; the final asymmetric status-count regression merged through PR #81 as04d3d316d. FinalmainGo CI and VulnCheck passed. Public documentation merged through silo.pgsty.com PR #7, and GitHub Pages plus Cloudflare Pages production deployment passed. Public EN/ZH routes were verified with HTTP 200. Issue #75 is closed as completed. No release tag, package, or container image was created by this closure.
Owner:pgsty/siloowns the server changes. This public design record belongs topgsty/silo.pgsty.com. Console UI remains a separate deliverable.
Decision: accept the useful feature, preserve the contributor’s work, and block release until site-replication deletes, recovery, wildcard responses, and the narrow protocol follow-ups converge correctly.
The problem and solution in plain language
Before PR #71, SILO could set CORS only for the whole cluster. A browser application could not say, “allow this website to use bucket A, but not bucket B.” Standard S3 calls for reading, writing, and deleting a bucket’s CORS configuration existed as stubs and returned NotImplemented.
PR #71 added the missing feature. A bucket can now store its own allowed websites, HTTP methods, request headers, exposed response headers, and preflight cache time. Standard S3 clients can manage the configuration, and buckets without one keep the old global behavior.
The core feature works. The remaining problem appears when the same bucket is replicated between sites.
Imagine an administrator allows https://old.example.com, then removes that permission. SILO correctly deletes the rule on the first site. If another site temporarily misses that delete, the recovery process must later learn that “deleted at 10:05” is newer than “configured at 10:00.” The current code sometimes forgets the deletion time or replaces the source time with the time at which a peer received the message. Recovery can then mistake the old live rule for the newest state and restore it.
The fix is not a new replication system. A deleted configuration is represented by the data SILO already has:
That pair is a deletion tombstone. The repair preserves the source timestamp for both PUT and DELETE, carries the timestamp even when no XML remains, and makes recovery use the same CORS apply path as normal peer delivery. Older events can no longer revive a newer deletion.
The direct cost is bounded: a CORS-specific distributed namespace lock and monotone state transition, focused ObjectLayer tests, strict wire validation, response-compatibility fixes, and operational documentation. There is no new storage field, dependency, feature flag, distributed clock, or general replication framework. The continuing cost is that SILO owns these tests and the bucket-CORS compatibility contract. Site replication still relies on synchronized wall clocks, as it already did.
CORS is not IAM authorization. A stale CORS rule does not grant an S3 permission that a principal lacks. It can, however, let a browser origin continue reading an already-authorized cross-origin response after an administrator intended to revoke that browser access. That is why convergence is a release blocker rather than cosmetic polish.
What PR #71 added
PR #71 replaced the inherited Bucket CORS stubs with a cohesive feature:
- standard
PutBucketCors,GetBucketCors, andDeleteBucketCorsAPIs; - Content-MD5 or supported checksum validation on PUT;
- XML parsing and validation for origins, methods, allowed headers, exposed headers, rule IDs, and max age;
- raw XML persistence in
BucketMetadatawith a CORS update timestamp; - per-bucket OPTIONS preflight handling and actual-response CORS headers;
- the existing global CORS policy as a fallback only for buckets without a per-bucket configuration;
- normal site-replication send, receive, initial-sync, status, and heal wiring;
- unit, handler, middleware, metadata, and transport tests.
The local review rebased the feature onto the then-current main, built it, ran focused normal and race tests, full cmd tests, pinned lint, generated-file checks, compatibility checks, and a real minio-go smoke test. PUT, GET, DELETE, allowed and rejected preflights, Vary, and actual response headers all worked in the single-site path.
This evidence justified accepting the feature. It did not prove every failure-recovery path.
The reproduced convergence failures
Review-only tests against the real ObjectLayer reproduced the three failures below. A later release review also proved that payload-only status comparison kept different source-time barriers hidden, and that equal-timestamp conflicts depended on arrival and map-iteration order.
A newer DELETE can be ignored
The peer handler used the ordinary metadata Update and Delete methods. Those methods assign UTCNow() on the receiving site. If an older PUT arrives late, its locally generated arrival time can appear newer than a later source DELETE, so the DELETE is discarded.
An older PUT can revive a deletion
GetCorsConfig returns not-found and a zero timestamp once the live config is nil. The deletion time still exists in raw bucket metadata, but the handler cannot see it through that getter. A stale PUT therefore passes the staleness check and restores the rule.
Heal can select the stale live rule
SiteReplicationMetaInfo currently exports CorsConfigUpdatedAt only when CORS XML is present. After DELETE, the site reports nil configuration and zero time. A peer that still has the old XML reports a non-zero older time. Heal selects that old rule as “latest” and writes it back.
These are the same failure expressed at three seams: peer apply, metadata status, and recovery.
The intended state model
Per-bucket CORS needs only the state already present in BucketMetadata:
| Logical state | XML | Timestamp | Meaning |
|---|---|---|---|
| Never configured | nil | zero | baseline; it is never transmitted or selected as a winner |
| Configured | non-nil | source PUT time | live per-bucket rule |
| Deleted | nil | source DELETE time | tombstone; newer than any earlier live rule |
The selected register uses a deterministic total order:
Peer apply is a monotone join: it applies only a strictly greater state, so
retry and duplicate delivery are idempotent. A tombstone wins an equal-time
PUT/DELETE conflict, while two live values choose the same bytewise winner at
every site. CreatedAt is not the baseline marker; it is only the bucket-lineage
floor that rejects an event from an older bucket incarnation and emits a
bucket-scoped diagnostic.
How the decision was made
Initial review
The first review agreed that the need was real and the single-site architecture was reasonable, but found that site replication emitted CORS events without completing every receive, status, and recovery path. The contributor added the missing wiring, checksum validation, wildcard/ID limits, cache variation, and focused tests.
A second runtime review confirmed the normal single-site and direct replication paths, then reproduced the tombstone and source-time failures above. The feature was close enough to accept, but not safe enough to release as complete.
Merge versus release
The maintainer chose to merge PR #71 and own the remaining hardening. This separated two decisions that are often confused:
- Is the contribution valuable and structurally sound enough to accept? Yes.
- Is the resulting feature ready to tag, package, publish, and deploy? Not until #75 closes.
The merge triggered full main CI, which passed. Release and Docker publication remain manual, independent gates.
Self-adversarial plan review
The first follow-up plan was intentionally comprehensive, then reviewed against four failure modes: overdesign, new problems introduced by the fix, failure to reuse existing infrastructure, and disproportionate maintenance cost.
That review removed or deferred:
- a new general metadata-apply abstraction;
- a custom wildcard matcher;
- broad policy/tag/SSE/quota refactoring;
- a multi-process site-replication test lab;
- method-case, Unicode-ID, and trailing-XML strictness without differential evidence;
- a no-Origin hot-path optimization that could alter existing
Varybehavior; - vector clocks, a new tombstone field, and a global timestamp redesign.
Independent Claude Opus 5 reviews
Four read-only local Claude Code reviews used canonical claude-opus-5 at
maximum effort. They moved the design from a timestamp-only patch to the final
zero-baseline, deterministic C-prime register; required the distributed CORS
lock and monotonic local barrier; made status and heal compare full state; and
closed strict base64, semantic validation, cache, Vary, wildcard credentials,
and initial-sync tombstone gaps.
The combined B2+B3 review then checked the strict parser, exact method and Unicode-ID contract, MaxAge presence, Origin-null forwarding marker, checksum classification, and replication/restart behavior together. It found a test helper conflict and the upgrade risk that a document accepted by a lenient development build could make all bucket metadata unavailable. The helper was corrected. Legacy-invalid CORS now leaves other bucket metadata readable, fails browser behavior closed, rejects new invalid saves, and remains repairable by a valid CORS PUT or DELETE.
Design goals and non-goals
Goals
- make CORS PUT and DELETE converge under duplicate, delayed, reordered, and missed events;
- preserve exact source timestamps on peer apply and heal;
- let a newer nil tombstone beat an older live config;
- avoid widening a configured bucket to global CORS on metadata failure;
- align literal wildcard, credentials, exposed headers, and cache variation with S3 behavior;
- correct the new CORS status count and the narrow validation gaps proven by existing matcher behavior;
- keep the repair independently reviewable and reversible.
Non-goals
- redesign every bucket metadata replication handler;
- solve distributed clock skew or same-timestamp multi-writer conflicts globally;
- add a new metadata schema, event log, queue, general metadata lock, or feature flag;
- build a permanent multi-site process lab;
- tighten unrelated XML or validation paths without evidence;
- add Console UI;
- mix historical Object Lock, tag, SSE, policy, quota, or versioning repairs into this branch.
Final repair design
Commit 1: preserve tombstones and source order
The CORS replication handler remains the single place for explicit peer CORS events.
Under a CORS-specific distributed namespace lock it will:
- require a non-empty bucket and non-zero source timestamp;
- require existing bucket metadata rather than fabricating it;
- read raw
CorsConfigUpdatedAt, including a timestamp whose live config is nil; - reject an event before the bucket lineage and ignore any state not strictly greater under the total order;
- strictly decode and validate a non-nil CORS payload or treat nil as DELETE;
- set
CorsConfigXMLandCorsConfigUpdatedAtdirectly from the source event, preserving the exact source barrier; - persist through
BucketMetadataSys.save, preserving the existing disk, cache, notification, and peer-node refresh path.
The legacy/default multi-field path may carry a non-nil CORS snapshot, so it
uses the same lock, strict validation, and join; typed deletes continue through
the CORS-specific handler. SiteReplicationMetaInfo always exports the source
timestamp and encodes XML only when present. Status compares kind, decoded
payload, and timestamp. Heal chooses the deterministic maximum and pushes it
through the same transition, including when only the timestamp differs.
The zero baseline is deliberately not defaulted to bucket creation. Initial
sync sends live and tombstone states but omits baseline. Local PUT and DELETE
choose a timestamp strictly after max(UTCNow, CreatedAt, current barrier).
Commit 2: fail closed and match S3 responses
The middleware currently falls back to global CORS for every GetCorsConfig error. The repair distinguishes two cases:
- true no-config: use the global policy, preserving existing behavior;
- another metadata error on a request with
Origin: log once and call the underlying S3 handler without global CORS headers.
This is fail-closed for browsers without converting a metadata problem into a new server-wide 500 contract. A failed preflight reaches the router’s ordinary non-CORS error response. Requests without Origin keep the existing middleware path; there is no speculative hot-path optimization.
Successful preflights also return configured Access-Control-Expose-Headers, which the S3 OPTIONS contract lists explicitly.
Origin matching will return the actual matched pattern. Response behavior is:
| Matched origin element | Access-Control-Allow-Origin |
Access-Control-Allow-Credentials |
|---|---|---|
* |
* |
omitted |
| exact origin | request origin | true |
pattern such as https://* |
request origin | true |
This matters when a rule contains both a specific origin and *: response semantics follow the first origin element that actually matched, rather than merely noticing that the rule contains a wildcard somewhere.
The three cache dimensions are set before the preflight match result, so both 200 and 403 responses vary by Origin, requested method, and requested headers.
Commit 3: narrow validation and status cleanup
The site summary increments TotalCorsConfigCount from the current site’s s.CorsConfig != nil, not from a cumulative count that may already include an earlier site.
Validation rejects:
- an empty allowed origin;
?, because the reused generic matcher treats it as a wildcard while S3 documents only a single*wildcard.
The implementation keeps the existing matcher and at-most-one-* rule. It does not change method case handling, ID character counting, or trailing XML behavior.
The handler test suite adds missing and mismatched Content-MD5 cases. That test exposed another concrete bug: the handler wrapped the checksum reader in an exact-ContentLength LimitReader, which returned EOF before the checksum wrapper could report a mismatched digest. After the existing positive and 64 KiB ContentLength guards, the handler now reads the wrapped request body to EOF directly. This preserves the shared validateLengthAndChecksum implementation and makes BadDigest observable without adding a second checksum path.
Commit 4: operator notes
The in-repository note records:
- bucket CORS overrides rather than merges with global CORS;
- DELETE restores global fallback;
- an older binary does not enforce the new configuration;
- an older binary rewriting bucket metadata may drop the unknown CORS fields;
- an older peer harmlessly no-ops an unknown CORS event, then converges through heal after upgrade;
- site replication still depends on synchronized clocks.
Public operational documentation remains a separate documentation-repository deliverable, represented by this record and any later task-oriented reference updates.
Test design
The tests exercise real state transitions without creating a permanent multi-process lab.
| Test seam | Required cases |
|---|---|
| Peer apply with ObjectLayer | delayed PUT then newer DELETE; stale PUT after tombstone; duplicate delivery; exact source timestamps; missing metadata returns an error and creates no record |
| Transport-to-apply | retain the existing JSON nil/non-nil round trip; feed at least one JSON-decoded event into the real peer handler |
| SiteReplicationMetaInfo | nil config still carries the DELETE timestamp; pre-feature zero timestamp defaults to Created |
| Heal | newer nil tombstone beats older live XML; local state becomes nil with the exact tombstone time |
| Middleware errors | no-config uses global fallback; another metadata error gives actual and preflight responses no global CORS headers |
| Origin responses | exact, literal *, patterned, and mixed-origin rules; credentials only when permitted |
| Preflight | expose headers; allowed headers; max age; three Vary fields on success and rejection |
| Validation and handler | empty origin, ?, missing Content-MD5, mismatched Content-MD5 |
The full admin-auth dispatch is not given its own integration fixture. It is a two-line switch already covered by compilation and review; the wire and real handler seams carry the meaningful state-machine risk. Startup’s concrete errBucketMetadataNotInitialized value is not frozen in a dedicated test; a representative non-not-found metadata error covers the middleware decision.
Rejected alternatives
Put CORS tombstone logic in the generic metadata merger
Rejected because it would give one field special nil, staleness, and early-return semantics inside a seven-field merge function. The CORS-specific handler already exists and is the smaller boundary.
Add a new timestamp-aware metadata abstraction
Rejected until at least two metadata types demonstrate identical requirements. A general helper today would encode assumptions about delete semantics that differ across policy, tag, SSE, object lock, quota, and versioning.
Add a physical tombstone field or event journal
Rejected because (nil config, DELETE timestamp) already represents the required state. A new schema increases downgrade and migration cost without adding information.
Replace wall clocks with a distributed ordering system
Rejected as disproportionate and inconsistent with existing site replication. Correctly preserving source time restores the current contract; it does not solve global clock skew.
Build a full multi-site test lab
Rejected because the failures are local state-machine defects and every important seam is directly testable in process. A lab would be slower, more brittle, and harder to diagnose.
Write a custom CORS wildcard matcher
Rejected because input validation can constrain the existing matcher to the S3-supported single-* language. Reimplementing matching creates more boundary cases than it removes.
Tighten every validation edge now
Rejected because uppercase-only methods, Unicode ID counting, and trailing-document rejection could change accepted inputs without evidence that they affect security or real client compatibility.
Fix every neighboring replication issue in the same branch
Rejected because shared-looking code does not prove shared semantics. Historical problems receive their own reproduction, issue, review, and release boundary.
Costs, benefits, and remaining risks
Benefits
- standard S3 Bucket CORS works for browser applications and common SDKs;
- buckets can use narrower origin policies than the cluster-wide fallback;
- normal delivery, missed events, reorder, retry, and heal converge on the same state;
- a revoked browser origin cannot be restored merely because a peer missed DELETE;
- error handling cannot silently widen a configured bucket to global CORS;
- wildcard and credentials behavior matches the established S3 client expectations.
Implementation and maintenance cost
The production changes remain local to bucket metadata timestamps, CORS peer apply/heal/status, CORS middleware, and CORS validation. The largest addition is regression coverage, because state convergence must be proven on both supported ObjectLayer test backends.
No new dependency, service, configuration key, storage field, background worker, or cross-repository server dependency is introduced. The ongoing cost is maintaining the S3 compatibility matrix, source-timestamp tests, and documentation.
Remaining risks accepted by design
- wall-clock ordering assumes synchronized site clocks;
- equal timestamps use a CORS-local deterministic tie-breaker rather than a global replication redesign;
- mixed-version operation is unsupported for CORS writes; all sites must upgrade before the feature is enabled;
- an old binary may ignore or later drop CORS metadata during rollback writes;
- full Console management remains absent;
- inherited replication defects outside CORS remain separate work.
These are visible constraints, not hidden claims of perfect parity.
Historical follow-ups kept separate
Adversarial review confirmed one unrelated defect in the existing initial-sync path: an Object Lock event is constructed with SRBucketMetaTypeObjectLockConfig but stores its payload in Tags instead of ObjectLockConfig. That requires a dedicated issue and fix.
Neighboring site summaries also use cumulative counters, and policy/tag/SSE/quota/versioning peer handlers may share source-time or tombstone weaknesses. The follow-up policy is:
- reproduce each behavior independently;
- open a focused issue with the affected metadata contract;
- do not modify it in the CORS branch;
- consider a shared helper only after at least two types require the same semantics.
This keeps historical cleanup honest without turning a bounded CORS repair into a site-replication rewrite.
An event earlier than local CreatedAt is ignored as belonging to an older
bucket incarnation and logged once with a bucket-scoped key. Status keeps the
mismatch visible; removing the floor would risk applying an old CORS grant to
a newly recreated bucket.
Compatibility impact
| Existing user or deployment | Expected impact |
|---|---|
| No bucket CORS configured | existing global CORS behavior remains |
| Single-site bucket CORS | standard control plane and enforcement remain; response fidelity improves |
| Site replication without bucket CORS | no behavioral change |
| Site replication with bucket CORS | source ordering, DELETE, retry, and heal become reliable |
| Raw PUT caller | must send the S3-required Content-MD5 or supported checksum |
| Older peer | no-ops unknown CORS events until upgrade; heal converges afterwards |
| Downgrade | bucket CORS is not enforced; metadata may be lost if an old binary rewrites the record |
| Console-only operator | no CORS editor yet; use SDK, CLI, or S3 API |
The stricter empty-origin and ? validation lands before any SILO release containing PR #71, so there is no released SILO bucket-CORS configuration population to migrate across that change.
Verification and release gates
The repair is complete only when all of the following are independently true:
- focused replication, middleware, validation, and handler tests pass;
- focused race tests pass;
- full
cmdtests pass; go build ./..., pinned lint, generated-file checks, and compatibility checks pass;- the standard
minio-goPUT/GET/DELETE and preflight smoke test passes; - an independent adversarial review finds no unresolved blocker;
- the follow-up server PR is committed, pushed, reviewed, and merged;
- its PR CI and the resulting
mainCI are green; - the documentation build and bilingual link checks pass;
- release tag, packages, image publication, deployment, and production verification are completed as separate gates.
Until then, issue #75 remains open and no release or Docker image should advertise per-bucket CORS as release-ready.
Conclusion
Per-bucket CORS solves a real compatibility and browser-isolation problem, and PR #71’s core implementation was worth accepting. The remaining defect is not a reason to discard the feature; it is a reason to state the replication model precisely and finish it before release.
The final design preserves the source timestamp and nil tombstone through normal peer apply, status, and heal; fails closed without turning CORS metadata errors into a new S3 outage; fixes literal wildcard and cache behavior; and keeps validation changes evidence-based. It reuses the existing CORS handler, bucket metadata, save path, matcher, and ObjectLayer tests. It adds no general framework and does not pull unrelated historical repairs into the branch.
That is the minimum complexity needed to make the merged feature sufficient, safe, and maintainable.
4.13 - Per-Bucket CORS Wire Contract: Strict XML, Checksums, and Browser Responses
This document records the B3 protocol-hardening decision for per-bucket CORS after SILO PR #71 merged as e4e3007da. It covers only the S3 request body, validation, checksum, matching, and browser-response contract. Site-replication ordering, tombstones, heal, status counters, and generic metadata refactoring remain separate work under SILO #75.
Status: The strict B3 contract and B2 convergence solution merged through pgsty/silo PR #80, with the remaining explicit status-count matrix merged through PR #81. Final
mainGo CI and VulnCheck passed. The EN/ZH records merged through documentation PR #7; GitHub Pages and Cloudflare Pages production deployment passed, and all four public CORS design routes return HTTP 200. Issue #75 is closed. This closure did not create a release tag, package, or container image.
Decision: implement the strict B3 wire contract before the first SILO release containing per-bucket CORS. Do not normalize invalid input into validity, do not broaden the patch into site replication, and do not claim an overall release GO from local B3 evidence.
Why this is a release blocker
Bucket CORS is a standard S3 control plane. Its input is not merely configuration-shaped text: raw clients sign an exact XML request body, modern AWS SDKs attach a required payload checksum, SILO stores the accepted bytes verbatim, and GetBucketCors later returns those bytes to strict XML clients.
Three adversarial cases exposed holes in the merged implementation:
- a valid
<CORSConfiguration>followed by a second XML root was accepted and stored; - an ID containing exactly 255 Unicode characters was rejected because Go’s
len(string)counted UTF-8 bytes; <AllowedMethod>get</AllowedMethod>was accepted because validation uppercased the value before checking the S3 enum.
These are server-side wire problems. The official AWS SDK models do not fully validate rule IDs or method strings on the client, and raw signed clients can always bypass typed SDK construction. The service must enforce the contract.
The second-root case is especially damaging. SILO stored the whole body, not just the first decoded element. A successful PUT could therefore make a later GET return a document with two roots, which standards-compliant XML clients reject.
Authoritative contract
The implementation uses current AWS documentation and generated SDK models as the protocol baseline:
- PutBucketCors defines the XML root, the 64 KB document limit, Content-MD5 and SDK checksum headers, up to 100 rules, and the rule-match conditions: origin, method, and every requested header must all match.
- CORSRule defines the uppercase method values and the inclusive 255-character ID limit.
- Elements of a CORS configuration permits at most one
*in each allowed origin or allowed header. - Testing CORS shows a successful preflight returning the matched rule’s full method list, requested allowed headers, exposed headers, credentials, and cache-variation headers.
- S3 error responses defines
MalformedXMLfor XML that does not validate against the S3 schema andBadDigestfor a mismatched Content-MD5 or checksum value. - The generated AWS SDK for Go v2 PutBucketCors operation marks the request checksum as required. Its CORS types use an
int32MaxAgeSeconds and leave most semantic validation to the server. - The WHATWG Fetch Standard forbids sharing a credentialed response when
Access-Control-Allow-Originis*.
A read-only OPTIONS request to the public AWS landsat-pds bucket independently confirmed the current response behavior: a wildcard rule returned Access-Control-Allow-Origin: *, the full GET, HEAD method list, and no Access-Control-Allow-Credentials header.
Reproduction classification
| Behavior | Result | Evidence and decision |
|---|---|---|
| second XML root accepted | REAL | parser, signed in-process handler, and real TCP SigV4 all accepted it before the fix |
| 255 Unicode-character ID rejected | REAL | parser/Validate, signed handler, and real boto3 request reproduced SILO’s rejection; accepting 255 code points is based on AWS’s character wording and SDK model, not authenticated AWS PUT |
| lowercase method accepted | REAL | parser/Validate, signed handler, and real boto3 request all reproduced it |
| 64 KiB boundary | NOT REAL | 65,536 bytes already passed and 65,537 failed; keep regression coverage |
| 100-rule boundary | NOT REAL | exactly 100 already passed and 101 failed; keep regression coverage |
| first fully matching rule | NOT REAL | matching already fell through an earlier header-restrictive rule; preserve that behavior |
| checksum EOF bypass | CONDITIONAL | an in-memory reader could hide EOF from the checksum wrapper, while real TCP already rejected bad digests; remove the reader-dependent behavior anyway |
| empty and unknown XML members | CONDITIONAL, resolved strictly | AWS schema/error documentation supports rejection, but no authenticated AWS PUT black-box result was available |
| wildcard origin plus credentials | REAL | merged SILO echoed the origin and enabled credentials for *; live AWS and Fetch require * without credentials |
Origin: null rewritten to wildcard |
REAL, found in final review | inner forwarding middleware rewrote an explicitly matched null origin to * while retaining credentials; B3 now marks its response so the legacy rewrite skips it |
| negative MaxAge rejection | CONDITIONAL, pre-existing | retained because browser max-age is non-negative; no authenticated AWS PUT differential was available |
Goals and non-goals
Goals
- accept exactly one S3 CORS document element and only XML Misc after it;
- enforce the documented 64 KiB, 100-rule, ID, method, wildcard, and MaxAge contracts;
- verify Content-MD5 and modern SDK checksums independent of reader chunking behavior;
- retain the first fully matching rule semantics;
- return S3-compatible successful preflight and actual-request headers;
- make every changed behavior reviewable through parser, Validate, signed handler, and real-client tests;
- keep the exported compatibility manifest unchanged.
Non-goals
- change site-replication delivery, tombstones, heal, or status accounting;
- redesign global CORS fallback or metadata-error handling;
- add Console UI;
- introduce a general XML-schema framework;
- validate arbitrary XML attributes or require one namespace spelling;
- enforce cross-rule ID uniqueness without stronger current evidence;
- refactor unrelated lifecycle, tagging, policy, SSE, quota, or versioning parsers;
- commit, push, tag, publish an image, deploy, or claim production parity in this work item.
Alternatives considered
A. Patch only the three reported lines
This would add an EOF check, use a rune count, and remove method uppercasing. It is attractive but incomplete: it leaves unknown elements, duplicate singleton fields, empty numeric values, int32 overflow, the generic ? wildcard, reader-dependent checksum verification, and incorrect wildcard/preflight responses.
Rejected: too narrow for the explicitly reviewed B3 contract.
B. Normalize input into a canonical configuration
The server could uppercase methods, trim values, discard unknown elements, and keep only the first XML root. This is convenient for friendly clients but changes invalid signed wire input into a different valid configuration. It also preserves bytes that do not round-trip through GetBucketCors cleanly.
Rejected: S3 compatibility requires validation, not silent repair.
C. Add a strict, B3-specific wire representation
Decode into private XML wire structs that capture direct text, unknown elements, repeated singleton fields, and MaxAge presence. Convert into the existing public Config and Rule types only after the XML shape is valid. Keep semantic checks in Validate and matching helpers.
Selected: it is strict where evidence exists, order-independent, namespace-tolerant, local to CORS, and adds no exported compatibility symbol.
D. Validate every possible XML and header detail
This would enforce namespace URIs, reject every unknown attribute, validate every response header as an RFC token, and add cross-rule ID uniqueness.
Rejected for now: these constraints lack sufficient differential evidence and risk unnecessary incompatibility.
Final design
1. XML wire parser
ParseBucketCorsConfig decodes into private wire-only types:
CORSConfigurationis the only root;- root and rule levels reject non-whitespace direct character data;
- unknown root, rule, and nested leaf elements are rejected;
IDandMaxAgeSecondsmay occur at most once per rule;- list members remain repeatable and order-independent;
- MaxAge text must parse as a signed 32-bit integer;
- after the root closes, whitespace, comments, and processing instructions are allowed; another root, text, directive, or malformed token is rejected.
Namespace prefixes and the standard namespace declaration remain accepted because matching uses XML local names. Unknown attributes are not newly rejected. The existing Config and Rule XML tags remain for serialization compatibility, but production request and metadata parsing uses ParseBucketCorsConfig.
This parser also runs when stored bucket metadata is loaded. That is a deliberate pre-release choice: no SILO tag postdates PR #71, so there is no released per-bucket CORS population to migrate. A development build that previously stored malformed CORS XML makes the bucket’s entire metadata record unloadable—not only its CORS view—until the stored CORS document is replaced or deleted.
2. Semantic validation
Validate enforces:
- one through 100 rules;
- valid UTF-8 and no more than 255 Unicode code points in an ID;
- at least one non-empty allowed origin and one method per rule;
- methods exactly equal to
GET,PUT,HEAD,POST, orDELETE; - no
?in an allowed origin or allowed header, because the inherited matcher treats it as a wildcard while S3 documents only*; - at most one
*in each allowed origin and allowed header; - non-empty allowed and exposed header elements;
- MaxAgeSeconds from zero through
2^31-1.
An empty ID remains allowed because ID itself is optional and current AWS documentation publishes no non-empty constraint. Cross-rule ID uniqueness remains outside this patch.
3. Matching
The generic matcher is replaced on this path by a small single-* matcher:
Allowed-header matching remains case-insensitive, while the requested header spelling is preserved in the response. Method matching is case-sensitive after the S3 PUT path validates canonical stored values. Direct site-replication and heal writes in the merged base bypass that validation; they remain a separate integration requirement and can otherwise store a method that the B3 matcher will not execute.
MatchPreflight continues past a rule that matches origin and method but rejects one requested header. The selected rule is therefore the first rule that matches all three documented conditions. It also returns the exact origin element that matched and whether MaxAgeSeconds was present, preserving the difference between absent and explicit zero.
4. Request size and checksums
The handler keeps the existing positive Content-Length and 64 KiB guards. validateLengthAndChecksum still wraps the body with the shared checker, but the CORS handler now reads that wrapped body to EOF instead of placing another exact-length LimitReader outside it.
This makes checksum verification independent of whether the underlying reader returns the final bytes with or without io.EOF in the same call. A well-formed but mismatched Content-MD5 or full-header SDK checksum returns BadDigest; missing checksum material still returns the existing required-checksum error. The shared helper can classify malformed checksum syntax as missing, and this small-body path does not implement aws-chunked trailing-checksum decoding; those fidelity gaps remain outside B3. No second checksum implementation is added.
Modern boto3 traffic is a material compatibility gate because current botocore sends x-amz-sdk-checksum-algorithm: CRC32 plus x-amz-checksum-crc32, not Content-MD5, for this required-checksum operation.
5. Browser responses
The matched origin element controls the response:
| Matched element | Access-Control-Allow-Origin |
Access-Control-Allow-Credentials |
|---|---|---|
* |
* |
omitted |
null |
null |
true |
| exact origin | request origin | true |
pattern such as https://* |
request origin | true |
A successful preflight returns:
- the matched rule’s complete
AllowedMethodslist; - only the requested headers that the rule permits;
- configured
ExposeHeaders; - MaxAgeSeconds, including explicit zero;
- the existing three successful-preflight
Varydimensions.
Actual requests keep their existing continue-through behavior and receive origin, credentials, expose, and Vary: Origin headers when a rule matches. A request-context marker prevents the inner legacy forwarding middleware from rewriting an explicitly allowed null origin to *; unmarked global responses retain their historical workaround. Because null is shared by sandboxed documents and file:// origins, operators should configure it only when credentialed access from all such contexts is intentional.
Allowed-origin elements are evaluated in document order. If a rule contains both a specific origin and *, place the specific origin first when that origin must retain reflected-origin credentials semantics.
The rejected-preflight body remains the existing bare 403 in this B3 patch. Producing the full AWS AccessForbidden XML shape and changing rejected-response cache/audit behavior require a separate wire decision rather than being smuggled into parser hardening.
Implementation map
| Area | Files | Responsibility |
|---|---|---|
| parser and validation | internal/bucket/cors/cors.go |
private wire structs, strict trailing-token check, rune/enum/wildcard/MaxAge validation, matching |
| parser tests | internal/bucket/cors/cors_test.go, cors_adversarial_test.go |
roots, XML Misc, unknown/nested/duplicate members, boundaries, matching |
| PUT handler | cmd/bucket-cors-handlers.go |
size/checksum gates, EOF consumption, S3 error mapping |
| signed handler tests | cmd/bucket-cors-adversarial_test.go |
three reported cases, 64 KiB, 100 rules, MD5 and CRC32 positive/negative cases |
| browser responses | cmd/api-router.go, cmd/generic-handlers.go |
matched origin semantics, null marker, full methods, expose, explicit zero max age |
| response tests | cmd/bucket-cors-middleware_test.go |
exact/pattern/wildcard/null origins, first full rule, headers, methods, expose, max age, credentials |
No site-replication source file belongs to this implementation boundary.
Test and evidence matrix
| Layer | Required evidence |
|---|---|
| parser | second root/text/dangling close rejected; trailing whitespace/comment/PI accepted; unknown/nested/duplicate rejected |
| Validate | 255 Unicode characters accepted, 256 rejected; lowercase and unsupported methods rejected; wildcard and empty-value cases |
| boundary | exactly 64 KiB and 100 rules accepted; one byte/rule over rejected; MaxAge absent/zero/negative/int32 overflow |
| signed handler | raw SigV4 PUT for all three reported failures; missing/bad MD5; valid/bad SDK CRC32 |
| middleware | first fully matching rule; wildcard, pattern, and null credentials; legacy unmarked null rewrite; full methods; requested headers; expose; explicit max age zero |
| focused race | CORS package and CORS handler/middleware tests under the race detector |
| full local | untagged and kqueue,dev full cmd; build; vet; pinned lint; generated/rebrand; diff check |
| real clients | minio-go v7.3.1 PUT/GET/preflight/DELETE and boto3/botocore CRC32 PUT/GET/preflight/DELETE plus adversarial rejects |
| external behavior | read-only OPTIONS against a public AWS bucket for wildcard, methods, credentials, and Vary |
Adversarial review resolution
Claude Code Opus 5 ran at max effort against the evidence, implementation, and then this bilingual design plus the final code. The earlier implementation review returned GO. The publication review returned GO WITH FIXES, with no P0 or P1 and five P2 findings. After the accepted code and documentation changes, the same session returned GO with no P0–P2 finding.
Its non-blocking findings were independently adjudicated:
- the one behavioral P2 was accepted: an explicitly allowed actual
Origin: nullnow survives the inner legacy forwarding middleware; - the metadata-load blast radius and replication-validation exception are now stated precisely;
- returning an interior MaxAge pointer is read-only and the selected rule was already an interior pointer; no new mutation occurs;
BadDigestis retained because the current AWS S3 error reference explicitly applies it to Content-MD5 or checksum mismatch;- malformed checksum syntax, trailing-checksum decoding, no-match
Vary, fullAccessForbiddenXML, and outer-middleware audit behavior are recorded but remain outside B3; - non-UTF-8 XML declarations are not enabled because the S3 request syntax is UTF-8 and current SDKs emit UTF-8;
- method whitespace remains invalid while integer whitespace is accepted according to their different XML lexical domains;
- validating every exposed header as an RFC token is deferred without AWS differential evidence.
Compatibility and rollout
| Existing use | Effect |
|---|---|
typed minio-go or boto3 CORS |
valid configurations continue to round-trip; modern CRC32 requests are verified |
| raw valid XML | accepted up to the same size and rule limits |
| lowercase method | now rejected instead of normalized |
| 255 non-ASCII ID characters | now accepted; more than 255 rejected |
| second root, unknown element, duplicate singleton, empty/overflow MaxAge | now rejected as malformed XML |
| literal wildcard origin | now returns * without credentials |
| patterned origin | concrete request origin remains reflected with credentials |
| old development metadata containing malformed CORS XML | the entire bucket metadata record may be unloadable until the CORS XML is replaced or deleted |
| site replication | no B3 code change; its own convergence repair and tests remain separate |
The strictness change lands before any tagged SILO version contains per-bucket CORS. That timing is the compatibility window. Once released, this wire contract becomes stable and future relaxations or tightenings require their own differential evidence.
Verification result and remaining gates
The final local implementation passed:
- focused parser, Validate, signed handler, and middleware tests;
- focused race tests for
internal/bucket/corsand CORScmdpaths; go test ./cmd -count=1and the fullkqueue,devcmdlane;go build ./...andgo vet ./...;- golangci-lint 2.13.1 with zero issues;
- generated-file, compatibility/rebrand, entrypoint, and diff checks;
- real boto3/botocore 1.43.58 and
minio-gov7.3.1 regressions against a freshly built local server; - final Claude Code Opus 5 max-effort review.
These results establish B3 IMPLEMENTATION GO only. Overall release remains blocked until the separate replication work is integrated, the server and documentation changes are committed and pushed, PR and merged-main CI pass, a release artifact is built, and deployment/production checks complete independently.
Conclusion
The final B3 design treats Bucket CORS as a signed S3 wire contract rather than a forgiving configuration file. It rejects malformed or noncanonical input before persistence, counts IDs as characters, validates modern SDK checksums, preserves documented first-full-rule selection, and emits browser-safe S3 responses.
The patch stays local to the CORS parser, validator, handler, matcher, response code, and tests. It adds no new service, schema, dependency, exported compatibility symbol, or site-replication refactor. That is the smallest complete solution supported by the protocol and live evidence.




