| 1 | package objstore |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/release" |
| 11 | ) |
| 12 | |
| 13 | // With no deny, the document has exactly two statements and no Condition key, |
| 14 | // so an enforcement-off store never carries a Deny. |
| 15 | // |
| 16 | // This is NOT "byte for byte what it was before scanning existed": the same |
| 17 | // change adds s3:GetObjectTagging so an app can read the verdict on its own |
| 18 | // object, which changes the bytes by definition. Every store's policy is |
| 19 | // rewritten once on its next grant re-walk, deliberately. |
| 20 | func TestStorePolicyJSONShapeWithoutDeny(t *testing.T) { |
| 21 | doc := storePolicyJSON("arn:aws:s3:::b", nil) |
| 22 | |
| 23 | var parsed policyDocument |
| 24 | if err := json.Unmarshal([]byte(doc), &parsed); err != nil { |
| 25 | t.Fatalf("not valid JSON: %v\n%s", err, doc) |
| 26 | } |
| 27 | // Two Allows plus the two anti-forgery Denys, which are NOT conditional on |
| 28 | // enforcement: an app must not be able to tag its own object whether or |
| 29 | // not this store denies reads of infected ones. The old shape asserted |
| 30 | // "no Deny at all without enforcement", which was the belief that let the |
| 31 | // upload-tagging route stay open. |
| 32 | if len(parsed.Statement) != 4 { |
| 33 | t.Fatalf("want two Allows and the two anti-forgery Denys, got %d:\n%s", |
| 34 | len(parsed.Statement), doc) |
| 35 | } |
| 36 | if strings.Contains(doc, "s3:ExistingObjectTag") { |
| 37 | t.Fatalf("a nil deny must emit no ENFORCEMENT deny:\n%s", doc) |
| 38 | } |
| 39 | if !strings.Contains(doc, "s3:GetObjectTagging") { |
| 40 | t.Fatalf("an app cannot read its own verdict without GetObjectTagging:\n%s", doc) |
| 41 | } |
| 42 | assertNoPutObjectTagging(t, doc) |
| 43 | } |
| 44 | |
| 45 | // With a deny, a third statement appears, conditioned on the engine's verdict |
| 46 | // tag. |
| 47 | func TestStorePolicyJSONWithDeny(t *testing.T) { |
| 48 | doc := storePolicyJSON("arn:aws:s3:::b", |
| 49 | &DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}) |
| 50 | |
| 51 | var parsed policyDocument |
| 52 | if err := json.Unmarshal([]byte(doc), &parsed); err != nil { |
| 53 | t.Fatalf("not valid JSON: %v\n%s", err, doc) |
| 54 | } |
| 55 | if len(parsed.Statement) != 5 { |
| 56 | t.Fatalf("want five statements with a deny, got %d:\n%s", len(parsed.Statement), doc) |
| 57 | } |
| 58 | deny := parsed.Statement[4] |
| 59 | if deny.Effect != "Deny" || len(deny.Action) != 1 || deny.Action[0] != "s3:GetObject" { |
| 60 | t.Fatalf("deny statement = %+v", deny) |
| 61 | } |
| 62 | if got, _ := deny.Condition["StringEquals"]["s3:ExistingObjectTag/"+TagVerdict].(string); got != "infected" { |
| 63 | t.Fatalf("deny does not condition on the verdict tag: %+v", deny.Condition) |
| 64 | } |
| 65 | assertNoPutObjectTagging(t, doc) |
| 66 | } |
| 67 | |
| 68 | // The anti-forgery property, asserted in one place — and asserted about BOTH |
| 69 | // routes, because it turned out to have two. |
| 70 | // |
| 71 | // The old version of this helper searched the whole document for the string |
| 72 | // "s3:PutObjectTagging" and failed if it appeared anywhere. That was the right |
| 73 | // instinct aimed one level too shallow: it proved the permission was not |
| 74 | // granted, which is not the same as proving an app cannot tag. The second |
| 75 | // route — x-amz-tagging on the upload — names no action at all, so no search |
| 76 | // for an action name could ever have found it. See storePolicyJSON's comment |
| 77 | // for what a real server did with it. |
| 78 | func assertNoPutObjectTagging(t *testing.T, doc string) { |
| 79 | t.Helper() |
| 80 | |
| 81 | var parsed policyDocument |
| 82 | if err := json.Unmarshal([]byte(doc), &parsed); err != nil { |
| 83 | t.Fatalf("not valid JSON: %v\n%s", err, doc) |
| 84 | } |
| 85 | |
| 86 | var deniesCall, deniesHeader bool |
| 87 | for _, st := range parsed.Statement { |
| 88 | for _, a := range st.Action { |
| 89 | if st.Effect == "Allow" && (a == "s3:PutObjectTagging" || a == "s3:DeleteObjectTagging") { |
| 90 | t.Fatalf("app must never be able to tag its own objects:\n%s", doc) |
| 91 | } |
| 92 | if st.Effect == "Deny" && a == "s3:PutObjectTagging" { |
| 93 | deniesCall = true |
| 94 | } |
| 95 | } |
| 96 | if st.Effect != "Deny" { |
| 97 | continue |
| 98 | } |
| 99 | keys, _ := st.Condition["ForAnyValue:StringEquals"]["s3:RequestObjectTagKeys"].([]any) |
| 100 | for _, k := range keys { |
| 101 | if k == TagVerdict { |
| 102 | deniesHeader = true |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | if !deniesCall { |
| 107 | t.Fatalf("nothing denies the PutObjectTagging call:\n%s", doc) |
| 108 | } |
| 109 | if !deniesHeader { |
| 110 | // The one a reviewer will not miss twice. |
| 111 | t.Fatalf("nothing denies x-amz-tagging carrying %s on upload — an app can ship a payload pre-marked clean:\n%s", TagVerdict, doc) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // The fake rewrites an EXISTING user's policy. MemProvisioner.EnsureUser |
| 116 | // early-returns when the principal is already there, and enforcement is only |
| 117 | // ever turned on for a store that already has one — so a fake that recorded |
| 118 | // the policy on the create branch alone would make the next test vacuous. |
| 119 | func TestMemProvisionerRewritesPolicyForExistingUser(t *testing.T) { |
| 120 | ctx := context.Background() |
| 121 | p := NewMemProvisioner("eu-west-1") |
| 122 | |
| 123 | if _, err := p.EnsureUser(ctx, "u", UserPath, "arn:aws:s3:::b", nil); err != nil { |
| 124 | t.Fatal(err) |
| 125 | } |
| 126 | // The ENFORCEMENT deny, specifically: the anti-forgery Denys are in every |
| 127 | // policy now, so `"Effect":"Deny"` no longer tells the two states apart. |
| 128 | if strings.Contains(p.PolicyFor("u"), "s3:ExistingObjectTag") { |
| 129 | t.Fatal("no enforcement deny was asked for") |
| 130 | } |
| 131 | if _, err := p.EnsureUser(ctx, "u", UserPath, "arn:aws:s3:::b", |
| 132 | &DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}); err != nil { |
| 133 | t.Fatal(err) |
| 134 | } |
| 135 | if !strings.Contains(p.PolicyFor("u"), "s3:ExistingObjectTag") { |
| 136 | t.Fatal("EnsureUser did not rewrite an existing user's policy") |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | // Flipping enforcement on a HEALTHY, fully-delivered store must reach the |
| 141 | // backend. |
| 142 | // |
| 143 | // Before EnsureUser was hoisted it could not: EnsureUser is the only |
| 144 | // PutUserPolicy caller, Grant returned at the idempotence short-circuit before |
| 145 | // ever reaching it, and Rotate never writes a policy at all — so enforcement |
| 146 | // would have been permanently inert on every store it was ever turned on for. |
| 147 | // This is the same bug internal/mail's walk fixed as "C2". |
| 148 | func TestGrantWritesDenyOnHealthyStore(t *testing.T) { |
| 149 | ctx := context.Background() |
| 150 | s := release.NewDirStore(t.TempDir()) |
| 151 | _, pub := testSealKeypair(t) |
| 152 | p := NewMemProvisioner("eu-west-1") |
| 153 | |
| 154 | // rotateFixture leaves a fully provisioned, fully delivered store — the |
| 155 | // exact state the short-circuit returns early on. |
| 156 | rotateFixture(t, s, pub, p) |
| 157 | user := UserName("bab", "titogo") |
| 158 | // The ENFORCEMENT deny: every policy now carries the two anti-forgery |
| 159 | // Denys, so the presence of a Deny no longer means enforcement is on. |
| 160 | if strings.Contains(p.PolicyFor(user), "s3:ExistingObjectTag") { |
| 161 | t.Fatal("no enforcement deny was asked for yet") |
| 162 | } |
| 163 | |
| 164 | // Turn enforcement on. Read-modify-write with the real etag: Put with "" |
| 165 | // is create-only and would return release.ErrCASConflict here. |
| 166 | stored, etag, err := Get(ctx, s, "bab", "titogo") |
| 167 | if err != nil || stored == nil { |
| 168 | t.Fatalf("re-read: %+v, %v", stored, err) |
| 169 | } |
| 170 | stored.Scanning = &ScanBlock{ |
| 171 | Enabled: &ScanEnabled{Engine: "clamav", At: time.Now().UTC(), By: "op@example.com"}, |
| 172 | Enforce: &DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}, |
| 173 | } |
| 174 | if _, err := Put(ctx, s, "bab", "titogo", stored, etag); err != nil { |
| 175 | t.Fatal(err) |
| 176 | } |
| 177 | |
| 178 | if _, err := Grant(ctx, s, p, testDeployment(), pub, |
| 179 | "bab", "titogo", "op@example.com", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | // s3:ExistingObjectTag, not `"Effect":"Deny"`: every policy carries the two |
| 183 | // anti-forgery Denys now, so the looser check would pass with the hoist |
| 184 | // entirely broken — a green test asserting nothing. |
| 185 | if !strings.Contains(p.PolicyFor(user), "s3:ExistingObjectTag") { |
| 186 | t.Fatalf("re-walking a healthy store did not write the enforcement deny — the hoist is not working:\n%s", |
| 187 | p.PolicyFor(user)) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | // The mirror: clearing Enforce and re-walking takes the deny back out. A |
| 192 | // `scan enforce off` that left a live Deny standing would be worse than never |
| 193 | // having offered the switch. |
| 194 | func TestGrantClearsDenyWhenEnforceIsCleared(t *testing.T) { |
| 195 | ctx := context.Background() |
| 196 | s := release.NewDirStore(t.TempDir()) |
| 197 | _, pub := testSealKeypair(t) |
| 198 | p := NewMemProvisioner("eu-west-1") |
| 199 | rotateFixture(t, s, pub, p) |
| 200 | user := UserName("bab", "titogo") |
| 201 | |
| 202 | stored, etag, err := Get(ctx, s, "bab", "titogo") |
| 203 | if err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | stored.Scanning = &ScanBlock{Enforce: &DenyTag{Key: TagVerdict, Value: string(VerdictInfected)}} |
| 207 | if _, err := Put(ctx, s, "bab", "titogo", stored, etag); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | if _, err := Grant(ctx, s, p, testDeployment(), pub, |
| 211 | "bab", "titogo", "op@example.com", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| 212 | t.Fatal(err) |
| 213 | } |
| 214 | if !strings.Contains(p.PolicyFor(user), "s3:ExistingObjectTag") { |
| 215 | t.Fatal("setup: the enforcement deny was never written") |
| 216 | } |
| 217 | |
| 218 | stored, etag, err = Get(ctx, s, "bab", "titogo") |
| 219 | if err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | stored.Scanning.Enforce = nil |
| 223 | if _, err := Put(ctx, s, "bab", "titogo", stored, etag); err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | if _, err := Grant(ctx, s, p, testDeployment(), pub, |
| 227 | "bab", "titogo", "op@example.com", GrantSourceOperator, "", time.Now().UTC()); err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if strings.Contains(p.PolicyFor(user), "s3:ExistingObjectTag") { |
| 231 | t.Fatalf("clearing Enforce left a live enforcement Deny standing:\n%s", p.PolicyFor(user)) |
| 232 | } |
| 233 | // And clearing enforcement must NOT have taken the anti-forgery Denys with |
| 234 | // it — they are unconditional, and a store that stops enforcing is exactly |
| 235 | // where a forged clean tag would go unnoticed longest. |
| 236 | assertNoPutObjectTagging(t, p.PolicyFor(user)) |
| 237 | } |
| 238 | |