patches: add v1.11.0 owner-scoped access patch and its leil-rig proof

0003-owner-scoped-access.patch changes authorization, applied after the
de-brand and dark-theme patches:

- a bucket's ACL and policy may name only the owner's keys (accounts on
  the owner's UserID), whoever writes them, root included;
- public access is read-only: all-users READ, and "*" allowed only
  s3:GetObject and s3:ListBucket;
- the bucket policy never denies the owner's key;
- DeleteBucket, PutBucketVersioning, PutObjectLockConfiguration and
  PutBucketOwnershipControls are reserved to root and admins;
- an ACL with the all-users READ grant can be written back unchanged.

docs/owner-scoped-access.md has the rules, what v1.11.0 already did,
and the leil-rig proof procedure with its 2026-11-10 result: 63 of 63
on the patched build, 29 of 63 failing on stock v1.11.0.
tests/owner-scope/ holds the proof script and a stdlib-only SigV4
request helper. README and the pipeline doc list the patch and how to
re-verify it after a rebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
57_WolveandClaude Opus 5.5 committed 2026-11-10 23:50:47 -06:00
1 parent 1984762186
commit 152ea01107
6 files changed
+1963 -6

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
#!/bin/bash
#
# Proof test for patches/v1.11.0/0003-owner-scoped-access.patch.
#
# sudo bash proof.sh /path/to/versitygw
#
# Starts a throwaway gateway from the given binary on 127.0.0.1:${PROOF_PORT:-7390},
# with its own internal IAM and posix root under ${PROOF_DIR:-/var/tmp/s3gw-owner-scope},
# drives every rule in the patch through real S3 requests, and removes the lot
# again. Nothing installed is touched: no package, no unit, no config. Prints one
# line per check and exits non-zero if any check fails. KEEP=1 leaves the gateway
# directory (and its log) behind for a look.
#
# The accounts, all made by root through the admin API:
#
# po-owner uid 74990 userplus owns bucket "proof"
# po-full uid 74990 user FULL_CONTROL grantee (an owner's key)
# po-obj uid 74990 user in no ACL; one policy statement on one object
# po-foreign uid 74991 user somebody else's key
#
# The UIDs are from leil-cluster's reserved test band (74990-74999); nothing
# is chowned, so they only have to be distinct.
set -uo pipefail
VGW=${1:?usage: proof.sh /path/to/versitygw}
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
PORT=${PROOF_PORT:-7390}
W=${PROOF_DIR:-/var/tmp/s3gw-owner-scope}
export S3_ENDPOINT=http://127.0.0.1:$PORT S3_REGION=us-east-1
[ -x "$VGW" ] || { echo "FATAL: $VGW is not an executable" >&2; exit 2; }
command -v python3 >/dev/null || { echo "FATAL: python3 is required" >&2; exit 2; }
if (exec 3<>"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null; then
echo "FATAL: something already listens on 127.0.0.1:$PORT (set PROOF_PORT)" >&2
exit 2
fi
rm -rf "$W" && mkdir -p "$W/iam" "$W/data" "$W/tmp" || exit 2
secret() { python3 -c 'import secrets; print(secrets.token_hex(16))'; }
declare -A KEY SECRET
KEY[root]=proofroot
for who in root po-owner po-full po-obj po-foreign; do
[ "$who" = root ] || KEY[$who]=$who
SECRET[$who]=$(secret)
done
"$VGW" --access "${KEY[root]}" --secret "${SECRET[root]}" --region "$S3_REGION" \
--port "127.0.0.1:$PORT" --iam-dir "$W/iam" posix "$W/data" >"$W/gateway.log" 2>&1 &
GW=$!
cleanup() {
kill "$GW" 2>/dev/null
wait "$GW" 2>/dev/null
[ "${KEEP:-0}" = 1 ] && echo "kept $W" || rm -rf "$W"
}
trap cleanup EXIT
for _ in $(seq 1 50); do
(exec 3<>"/dev/tcp/127.0.0.1/$PORT") 2>/dev/null && break
kill -0 "$GW" 2>/dev/null || { cat "$W/gateway.log"; echo "FATAL: the gateway exited" >&2; exit 2; }
sleep 0.2
done
echo "gateway $("$VGW" --version 2>&1 | head -1) on 127.0.0.1:$PORT, sha256 $(sha256sum "$VGW" | cut -c1-16)"
admin() {
ADMIN_ACCESS_KEY_ID=${KEY[root]} ADMIN_SECRET_ACCESS_KEY=${SECRET[root]} \
"$VGW" admin --region "$S3_REGION" --endpoint-url "$S3_ENDPOINT" "$@"
}
# req <who> <s3req.py args...> -- one request as <who> ("anon" for none); prints the status
req() {
local who=$1; shift
if [ "$who" = anon ]; then
S3_KEY= S3_SECRET= python3 "$HERE/s3req.py" "$@" 2>"$W/tmp/err"
else
S3_KEY=${KEY[$who]} S3_SECRET=${SECRET[$who]} python3 "$HERE/s3req.py" "$@" 2>"$W/tmp/err"
fi
}
PASS=0 FAIL=0
# check <id> <want> <what> <who> <s3req.py args...>
check() {
local id=$1 want=$2 what=$3; shift 3
local got
got=$(req "$@")
if [ "$got" = "$want" ]; then
PASS=$((PASS + 1)); printf 'PASS %-4s %s %s\n' "$id" "$got" "$what"
else
FAIL=$((FAIL + 1)); printf 'FAIL %-4s %s (want %s) %s [%s]\n' "$id" "${got:-none}" "$want" "$what" "$(cat "$W/tmp/err")"
fi
}
# check_cmd <id> <want: ok|refused> <what> <command...>
check_cmd() {
local id=$1 want=$2 what=$3; shift 3
local got=ok
"$@" >"$W/tmp/err" 2>&1 || got=refused
if [ "$got" = "$want" ]; then
PASS=$((PASS + 1)); printf 'PASS %-4s %s %s\n' "$id" "$got" "$what"
else
FAIL=$((FAIL + 1)); printf 'FAIL %-4s %s (want %s) %s [%s]\n' "$id" "$got" "$want" "$what" "$(tr '\n' ' ' <"$W/tmp/err")"
fi
}
doc() { printf '%s' "$2" >"$W/tmp/$1"; echo "$W/tmp/$1"; }
policy() { doc policy.json "{\"Version\":\"2012-10-17\",\"Statement\":[$1]}"; }
acl_body() { # acl_body <grantee> <type> <permission>
doc acl.xml "<AccessControlPolicy xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\"><Owner><ID>po-owner</ID></Owner><AccessControlList><Grant><Grantee xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:type=\"$2\"><ID>$1</ID></Grantee><Permission>$3</Permission></Grant></AccessControlList></AccessControlPolicy>"
}
BUCKET_RES='["arn:aws:s3:::proof","arn:aws:s3:::proof/*"]'
S3NS='xmlns="http://s3.amazonaws.com/doc/2006-03-01/"'
echo "--- setup (root, admin API)"
check_cmd S1 ok "create-user po-owner" admin create-user --access po-owner --secret "${SECRET[po-owner]}" --role userplus --user-id 74990 --group-id 74990
check_cmd S2 ok "create-user po-full" admin create-user --access po-full --secret "${SECRET[po-full]}" --role user --user-id 74990 --group-id 74990
check_cmd S3 ok "create-user po-obj" admin create-user --access po-obj --secret "${SECRET[po-obj]}" --role user --user-id 74990 --group-id 74990
check_cmd S4 ok "create-user po-foreign" admin create-user --access po-foreign --secret "${SECRET[po-foreign]}" --role user --user-id 74991 --group-id 74991
check_cmd S5 ok "create-bucket proof --owner po-owner --grant-full-control po-full" \
admin create-bucket --owner po-owner --bucket proof --object-ownership BucketOwnerPreferred --grant-full-control po-full
printf 'hello\n' >"$W/tmp/hello.txt"
echo "--- a foreign key is refused as a grantee"
check_cmd A1 refused "admin create-bucket --grant-full-control po-foreign (root)" \
admin create-bucket --owner po-owner --bucket proof-foreign --object-ownership BucketOwnerPreferred --grant-full-control po-foreign
check A2 404 " ...and no bucket proof-foreign was made" root HEAD /proof-foreign
check A3 403 "PutBucketAcl x-amz-grant-read: po-foreign, as the owner" po-owner PUT /proof acl -H "x-amz-grant-read: po-foreign"
check A4 403 "PutBucketAcl x-amz-grant-read: po-foreign, as root" root PUT /proof acl -H "x-amz-grant-read: po-foreign"
check A5 403 "PutBucketAcl body naming po-foreign, as root" root PUT /proof acl --body "$(acl_body po-foreign CanonicalUser FULL_CONTROL)"
check A6 200 "PutBucketAcl x-amz-grant-full-control: po-full (owner's key)" po-owner PUT /proof acl -H "x-amz-grant-full-control: po-full"
echo "--- a foreign key is refused as a principal"
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"po-foreign\"},\"Action\":\"s3:GetObject\",\"Resource\":\"arn:aws:s3:::proof/*\"}")
check B1 403 "PutBucketPolicy naming po-foreign, as the owner" po-owner PUT /proof policy --body "$P"
check B2 403 "PutBucketPolicy naming po-foreign, as root" root PUT /proof policy --body "$P"
echo "--- public access is read-only"
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":\"s3:PutObject\",\"Resource\":\"arn:aws:s3:::proof/*\"}")
check C1 403 "PutBucketPolicy allowing \"*\" s3:PutObject" po-owner PUT /proof policy --body "$P"
check C2 403 "PutBucketAcl x-amz-acl: public-read-write" po-owner PUT /proof acl -H "x-amz-acl: public-read-write"
check C3 403 "PutBucketAcl body granting all-users WRITE" po-owner PUT /proof acl --body "$(acl_body all-users Group WRITE)"
check C4 200 "owner PutObject public/hello.txt" po-owner PUT /proof/public/hello.txt --body "$W/tmp/hello.txt"
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":\"*\",\"Action\":[\"s3:GetObject\",\"s3:ListBucket\"],\"Resource\":$BUCKET_RES}")
check C5 204 "PutBucketPolicy allowing \"*\" s3:GetObject + s3:ListBucket" po-owner PUT /proof policy --body "$P"
check C6 200 "anonymous GetObject public/hello.txt" anon GET /proof/public/hello.txt
check C7 200 "anonymous ListBucket" anon GET /proof list-type=2
check C8 403 "anonymous PutObject" anon PUT /proof/public/anon.txt --body "$W/tmp/hello.txt"
echo "--- the owner key keeps its bucket under a public-read-only policy"
check D1 200 "owner PutObject" po-owner PUT /proof/owner/a.txt --body "$W/tmp/hello.txt"
check D2 200 "owner GetObject" po-owner GET /proof/owner/a.txt
check D3 204 "owner DeleteObject" po-owner DELETE /proof/owner/a.txt
check D4 200 "owner GetBucketAcl" po-owner GET /proof acl
echo "--- ...and under a policy that denies it"
P=$(policy "{\"Effect\":\"Deny\",\"Principal\":{\"AWS\":\"po-owner\"},\"Action\":\"s3:*\",\"Resource\":$BUCKET_RES},{\"Effect\":\"Deny\",\"Principal\":\"*\",\"Action\":\"s3:PutObject\",\"Resource\":\"arn:aws:s3:::proof/*\"}")
check E1 204 "PutBucketPolicy denying po-owner s3:* and \"*\" s3:PutObject" po-owner PUT /proof policy --body "$P"
check E2 200 "owner PutObject" po-owner PUT /proof/owner/b.txt --body "$W/tmp/hello.txt"
check E3 200 "owner GetObject" po-owner GET /proof/owner/b.txt
check E4 200 "owner ListBucket" po-owner GET /proof list-type=2
check E5 204 "owner DeleteObject" po-owner DELETE /proof/owner/b.txt
check E6 403 "FULL_CONTROL grantee PutObject: the Deny beats its ACL grant" po-full PUT /proof/full/b.txt --body "$W/tmp/hello.txt"
check E7 200 "FULL_CONTROL grantee GetObject: the ACL still grants it" po-full GET /proof/public/hello.txt
check E8 204 "owner DeleteBucketPolicy" po-owner DELETE /proof policy
echo "--- a key for one object"
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"po-obj\"},\"Action\":[\"s3:GetObject\",\"s3:PutObject\",\"s3:DeleteObject\"],\"Resource\":\"arn:aws:s3:::proof/shared/report.txt\"}")
check F1 204 "PutBucketPolicy for po-obj on shared/report.txt" po-owner PUT /proof policy --body "$P"
check F2 200 "po-obj PutObject shared/report.txt" po-obj PUT /proof/shared/report.txt --body "$W/tmp/hello.txt"
check F3 200 "po-obj GetObject shared/report.txt" po-obj GET /proof/shared/report.txt
check F4 403 "po-obj GetObject public/hello.txt" po-obj GET /proof/public/hello.txt
check F5 403 "po-obj PutObject shared/other.txt" po-obj PUT /proof/shared/other.txt --body "$W/tmp/hello.txt"
check F6 403 "po-obj DeleteObject public/hello.txt" po-obj DELETE /proof/public/hello.txt
check F7 403 "po-obj ListBucket" po-obj GET /proof list-type=2
check F8 403 "po-obj GetBucketAcl" po-obj GET /proof acl
check F9 403 "po-obj PutBucketAcl" po-obj PUT /proof acl -H "x-amz-grant-read: po-obj"
check F10 403 "po-obj GetBucketPolicy" po-obj GET /proof policy
check F11 403 "po-obj PutBucketPolicy" po-obj PUT /proof policy --body "$P"
check F12 204 "po-obj DeleteObject shared/report.txt" po-obj DELETE /proof/shared/report.txt
echo "--- bucket settings are root's and admins' alone"
OWN=$(doc own.xml "<OwnershipControls $S3NS><Rule><ObjectOwnership>BucketOwnerPreferred</ObjectOwnership></Rule></OwnershipControls>")
VER=$(doc ver.xml "<VersioningConfiguration $S3NS><Status>Enabled</Status></VersioningConfiguration>")
LCK=$(doc lck.xml "<ObjectLockConfiguration $S3NS><ObjectLockEnabled>Enabled</ObjectLockEnabled></ObjectLockConfiguration>")
# A FULL_CONTROL grant alone never reached these four; a policy did, so grant them outright.
P=$(policy "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":\"po-full\"},\"Action\":[\"s3:PutBucketOwnershipControls\",\"s3:PutBucketVersioning\",\"s3:PutBucketObjectLockConfiguration\",\"s3:DeleteBucket\"],\"Resource\":\"arn:aws:s3:::proof\"}")
check G0 204 "PutBucketPolicy granting po-full all four" po-owner PUT /proof policy --body "$P"
check G1 403 "FULL_CONTROL grantee PutBucketOwnershipControls" po-full PUT /proof ownershipControls --body "$OWN"
check G2 403 "FULL_CONTROL grantee DeleteBucketOwnershipControls" po-full DELETE /proof ownershipControls
check G3 403 "FULL_CONTROL grantee PutBucketVersioning" po-full PUT /proof versioning --body "$VER"
check G4 403 "FULL_CONTROL grantee PutObjectLockConfiguration" po-full PUT /proof object-lock --body "$LCK"
check G5 403 "FULL_CONTROL grantee DeleteBucket" po-full DELETE /proof
check G6 403 "owner PutBucketVersioning" po-owner PUT /proof versioning --body "$VER"
check G7 403 "owner PutObjectLockConfiguration" po-owner PUT /proof object-lock --body "$LCK"
check G8 403 "owner DeleteBucket" po-owner DELETE /proof
check G9 204 "owner DeleteBucketPolicy" po-owner DELETE /proof policy
check G10 200 "FULL_CONTROL grantee PutObject (its grant still works)" po-full PUT /proof/full/c.txt --body "$W/tmp/hello.txt"
check G11 200 "root PutBucketOwnershipControls" root PUT /proof ownershipControls --body "$OWN"
echo "--- an ACL with the public read grant writes back unchanged"
check H1 200 "PutBucketAcl x-amz-acl: public-read" po-owner PUT /proof acl -H "x-amz-acl: public-read"
check H2 200 "GetBucketAcl" po-owner GET /proof acl -o "$W/tmp/acl-read.xml"
check H3 200 "PutBucketAcl with that body, unchanged" po-owner PUT /proof acl --body "$W/tmp/acl-read.xml"
check H4 200 "GetBucketAcl again" po-owner GET /proof acl -o "$W/tmp/acl-again.xml"
check_cmd H5 ok " ...and it still grants all-users READ" \
grep -q '<ID>all-users</ID>.*<Permission>READ</Permission>' "$W/tmp/acl-again.xml"
echo "--- cleanup (root)"
for k in public/hello.txt full/c.txt; do req root DELETE "/proof/$k" >/dev/null; done
check Z1 204 "root DeleteBucket" root DELETE /proof
echo
echo "$PASS passed, $FAIL failed"
[ "$FAIL" = 0 ]
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""One S3 request, SigV4-signed with the key in the environment.
S3_ENDPOINT=http://127.0.0.1:7390 S3_KEY=ak S3_SECRET=sk \
s3req.py PUT /bucket/key [query] [--body FILE] [-H 'Name: value'] [-o FILE]
Prints the HTTP status on stdout, and for an error response the S3 error
code and message on stderr. With S3_KEY empty or unset the request is
anonymous. Python standard library only, so it runs on a bare EL9 node.
"""
import base64
import datetime
import hashlib
import hmac
import http.client
import os
import re
import sys
import urllib.parse
UNRESERVED = "-_.~"
def quote(s, safe=""):
return urllib.parse.quote(s, safe=UNRESERVED + safe)
def canonical_query(query):
pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
return "&".join(f"{quote(k)}={quote(v)}" for k, v in sorted(pairs))
def sign(method, host, path, query, headers, body, key, secret, region):
now = datetime.datetime.now(datetime.timezone.utc)
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
day = now.strftime("%Y%m%d")
headers["host"] = host
headers["x-amz-date"] = amz_date
headers["x-amz-content-sha256"] = hashlib.sha256(body).hexdigest()
signed = sorted(headers)
canonical = "\n".join([
method,
quote(path, safe="/"),
canonical_query(query),
"".join(f"{h}:{headers[h].strip()}\n" for h in signed),
";".join(signed),
headers["x-amz-content-sha256"],
])
scope = f"{day}/{region}/s3/aws4_request"
to_sign = "\n".join(["AWS4-HMAC-SHA256", amz_date, scope,
hashlib.sha256(canonical.encode()).hexdigest()])
k = ("AWS4" + secret).encode()
for part in (day, region, "s3", "aws4_request"):
k = hmac.new(k, part.encode(), hashlib.sha256).digest()
signature = hmac.new(k, to_sign.encode(), hashlib.sha256).hexdigest()
headers["authorization"] = (f"AWS4-HMAC-SHA256 Credential={key}/{scope}, "
f"SignedHeaders={';'.join(signed)}, Signature={signature}")
def main(argv):
args, headers, body, out = [], {}, b"", None
it = iter(argv)
for a in it:
if a == "--body":
path = next(it)
body = sys.stdin.buffer.read() if path == "-" else open(path, "rb").read()
elif a == "-H":
name, _, value = next(it).partition(":")
headers[name.strip().lower()] = value.strip()
elif a == "-o":
out = next(it)
else:
args.append(a)
if len(args) not in (2, 3):
sys.exit(__doc__)
method, path = args[0].upper(), args[1]
query = args[2] if len(args) == 3 else ""
endpoint = urllib.parse.urlsplit(os.environ.get("S3_ENDPOINT", "http://127.0.0.1:7390"))
region = os.environ.get("S3_REGION", "us-east-1")
key, secret = os.environ.get("S3_KEY", ""), os.environ.get("S3_SECRET", "")
if body:
headers["content-md5"] = base64.b64encode(hashlib.md5(body).digest()).decode()
if key:
sign(method, endpoint.netloc, path, query, headers, body, key, secret, region)
conn = http.client.HTTPConnection(endpoint.hostname, endpoint.port, timeout=30)
target = quote(path, safe="/") + ("?" + query if query else "")
conn.request(method, target, body=body if method in ("PUT", "POST") else None, headers=headers)
resp = conn.getresponse()
data = resp.read()
if out:
with open(out, "wb") as f:
f.write(data)
print(resp.status)
if resp.status >= 300 and data:
code = re.search(rb"<Code>(.*?)</Code>", data)
msg = re.search(rb"<Message>(.*?)</Message>", data)
print((code.group(1) if code else b"").decode(), "-",
(msg.group(1) if msg else data[:200]).decode(errors="replace"), file=sys.stderr)
if __name__ == "__main__":
main(sys.argv[1:])