The assumption
Public cloud storage exposure has been a well-known problem for long enough that "we set the bucket to private" felt, reasonably, like a settled question. If the bucket-level setting says private, the natural inference is that the objects inside inherit that setting, the same way a locked front door implies the rooms behind it are inaccessible. I held that assumption for years across multiple cloud providers before actually testing it against the object layer specifically, rather than the bucket layer, which is the layer almost every scanning tool and every compliance checklist actually looks at.
The key idea
A bucket's access control list is a default, not a ceiling. Any object with its own, more permissive ACL overrides the bucket-level setting for that object specifically, which means "the bucket is private" is not the same claim as "every object in it is private."
The problem
OSS access control operates at two independent layers: the bucket-level ACL (which sets the default for objects that don't specify their own) and the object-level ACL (which, when explicitly set, overrides the bucket default for that specific object). This is a genuinely useful feature: it lets you keep a bucket private overall while deliberately making a handful of specific objects public, like serving public assets (a logo, a public PDF, a downloadable installer) from an otherwise-private bucket that also holds internal data. The same mechanism that enables that legitimate use case also means a single API call, made once, by a script, a misconfigured upload tool, or a well-meaning developer testing something, can make one object in an otherwise-locked-down bucket publicly readable, permanently, until someone notices.
And "someone notices" is doing a lot of work in that sentence, because a bucket-level scan that only checks the bucket's own ACL will report the bucket as private and be entirely correct, while missing every object that silently overrides it. The scan isn't wrong. It's answering a narrower question than the one being asked of it, and the narrowing happens silently, inside the scanning tool's own scope decision, not in anything visible to whoever reads the report.
How the override actually happens in practice
The failure modes I've seen aren't dramatic. Nobody sits down and decides to expose a file. A developer testing a "shareable link" feature sets an object to public-read to confirm the feature works, and forgets to revert it once the test passes, because the test passing is the thing that ended the task in their mind. An upload script inherited from an older project has a --acl public-read flag baked into its defaults because an earlier version of the bucket was meant to serve public assets, and nobody updated the flag when the script got repurposed for a different, more sensitive bucket. A migration tool copying objects between buckets sometimes carries over source ACLs by default rather than applying the destination bucket's policy, which means an object that was legitimately public in its old home arrives just as public in a bucket where that was never intended.
The experiment
I created an OSS bucket with the bucket ACL set to private, uploaded a set of test objects representative of a realistic mixed bucket (internal reports, a couple of genuinely public assets, and a scattering of miscellaneous uploads), and then set one single object's ACL to public-read using a one-line API call, deliberately reproducing the kind of action that happens accidentally when someone follows an outdated tutorial, tests a "make this file shareable" feature, or runs an upload script with an incorrect default flag.
# Bucket ACL: private (set at creation, unchanged since)
ossutil api put-bucket-acl --bucket my-data-bucket --acl private
# One object, one command, overrides it for that object only
ossutil api put-object-acl --bucket my-data-bucket \
--key reports/q3-internal-summary.pdf --object-acl public-read
One prerequisite worth stating explicitly, because it's easy to get a false negative without it: Alibaba Cloud rolled out Block Public Access as an account-level default starting in late 2025, and a bucket created under current defaults will have it on. With Block Public Access enabled, a public-read object ACL is silently ignored and anonymous requests are still denied, which would have made this entire experiment a non-event. I turned Block Public Access off for this bucket before running the test, specifically so the object ACL override could actually take effect and be observed. If you're checking this on an account provisioned after that rollout, that toggle is the first thing to verify, not the object ACL.
I then ran two separate checks: a bucket-level ACL audit (the kind of check most security scanning tools run by default, because it's cheap: one API call per bucket) and an object-level ACL enumeration across every object in the bucket (expensive at scale, and something I've rarely seen run as a routine check rather than an incident-response step).
# the cheap check: one call, answers a bucket-scoped question
ossutil api get-bucket-acl --bucket my-data-bucket
# {"Owner": "...", "Grants": [{"Grantee": "CanonicalUser", "Permission": "FULL_CONTROL"}]}
# -> reported as: private
# the expensive check: one call per object, answers an object-scoped question
for key in $(ossutil ls oss://my-data-bucket -s); do
acl=$(ossutil api get-object-acl --bucket my-data-bucket --key "$key")
echo "$key: $acl"
done | grep -i "public-read"
# reports/q3-internal-summary.pdf: public-read
The proof
The bucket-level scan, correctly, missed it
The bucket-level audit reported the bucket as private, correctly, and would have satisfied any compliance check or security review that stopped at that layer. There's no ambiguity in that result and no bug being exercised: the bucket's own ACL genuinely was private, unchanged since creation, and a tool answering "what is this bucket's ACL" gave the true answer.
The object-level enumeration found what the bucket check couldn't see
The object-level enumeration found the one object with a public-read ACL, fetchable by anyone with the URL, with no authentication required, sitting inside a bucket that every bucket-level tool would have cleared. I confirmed this by fetching the object anonymously from a separate machine with no OSS credentials configured at all, using nothing but the object's URL, and it returned the file's full contents.
curl -s -o downloaded_report.pdf \
"https://my-data-bucket.oss-cn-hangzhou.aliyuncs.com/reports/q3-internal-summary.pdf"
# 200 OK, file downloaded, zero credentials presented
At the scale of a single test object, this is a curiosity. At the scale of a real production bucket with thousands or millions of objects accumulated over years, across multiple teams and multiple upload tools with their own histories, an object-level ACL scan is exactly the kind of check that's expensive enough to get skipped in favor of the cheaper bucket-level one, which means the actual exposure surface (individual public objects) goes unaudited by default, in the exact configuration most teams believe constitutes "we checked."
What the cost curve actually looks like at scale
I re-ran the object enumeration against a synthetic bucket seeded with a hundred thousand objects, to get a rough sense of what "expensive" means in practice rather than leaving it as an abstract claim. A single get-object-acl call per object, even parallelized reasonably aggressively, ran into request-rate considerations and took long enough that it wasn't something anyone would casually re-run on a whim before a demo or a routine check-in, the way a bucket-level ACL call comfortably is. That asymmetry (one call versus a call per object) is exactly why bucket-level checks get run habitually and object-level checks get run only when someone already suspects a problem, which is precisely backwards from a risk standpoint: the check that catches silent, individual exposures is the one that's least likely to run before something goes wrong, not after.
There's a partial mitigation worth naming here, because it changes the economics meaningfully: OSS supports bucket inventory reports, which can include object ACL information as part of a periodic, server-side generated report rather than requiring a client to enumerate and query every object individually. Using inventory reports moves the expensive part of the check from "your infrastructure, run synchronously, right before you need the answer" to "Alibaba Cloud's infrastructure, run on a schedule, delivered as a file you read later." I didn't have this configured during the initial test, which is itself representative: it's an opt-in feature, not a default, and a team has to know to reach for it before the exposure it would have caught becomes relevant.
You might disagree
You could argue this is working as designed: object-level ACL overrides are a documented, deliberate OSS feature, not a security flaw, and any team that wants object-level auditing has the tooling available to run it. That's correct, and I'm not calling this a bug. Alibaba Cloud's documentation is explicit that object ACLs take precedence over the bucket default when set, and the feature exists because the legitimate use case (a mixed public/private bucket) is common enough to justify it.
The issue is that the default posture of most security tooling (checking the cheap, bucket-level signal) creates a false sense of completeness, and nothing about the bucket-level "private" status warns you that it doesn't cover object-level exceptions. A tool that reports "bucket: private" without a corresponding "objects with overriding ACLs: N" number is reporting a partial answer as if it were a complete one, and the phrasing of the report is doing real epistemic damage here: "private" reads as a closed, binary, verified state, not as "private by default, pending object-level exceptions I did not check for." A more honest report wouldn't need to run the expensive scan every time; it would just need to say what it didn't check, the same way a partial test suite's coverage report says which lines it didn't exercise rather than implying full coverage by omission.
What I think now
I now treat bucket-level ACL audits as necessary and explicitly insufficient, and I ask, for any bucket handling sensitive data, whether object-level ACL enumeration runs as a routine, scheduled check, not just as something reached for after an exposure is already suspected. On OSS specifically, that means periodically listing objects and checking for any ACL that diverges from the bucket default, rather than trusting that "bucket: private" covers everything inside it. For buckets large enough that a full enumeration is genuinely expensive, I'd rather run it on a longer interval and accept the latency than skip it entirely, because the alternative isn't "no risk," it's "unmeasured risk," and those aren't the same thing even though they can feel similar from inside a clean-looking dashboard.
I've also started pushing back, specifically, on any upload tooling that sets object ACLs implicitly rather than requiring an explicit flag, because implicit ACL-setting is exactly the mechanism behind every accidental exposure I've seen, including my own test case. A tool that requires you to type --acl public-read deliberately is a tool that makes the mistake visible in the command itself, which is a small design choice with a disproportionate effect on how often it happens by accident.
The other change was smaller but has stuck: I stopped saying "the bucket is private" as a complete sentence in any handoff document, incident report, or architecture review. I now say "the bucket ACL is private, and object-level ACLs were last checked on [date]," which is a more annoying sentence to write and a much more honest one. It forces whoever reads it to notice that the second half has a date attached, and a date implies the check can go stale, which is exactly the property the shorter sentence hid.
The lesson learned
Bucket-level privacy settings are a default, not a guarantee, because object-level ACLs can silently override them for individual objects. The cheap check (is the bucket private) is not the check that would have caught this. The expensive one (does every object's ACL match what the bucket implies) is the one that actually answers the question "is our data exposed," and running only the first one while believing you've answered the second is the specific gap this experiment was built to make visible.