Session 06 ยท ๐Ÿชช Ownership

Ownership & IDOR

A valid token proves who you are โ€” not that this row is yours.

โœ“
6athe IDOR attack
โœ“
6bgeneric ownership guard

6a

Change one number in the URL

Insecure Direct Object Reference: the server trusts the id in the request. Asha is user 7.

โœ—Authentication passed every time โ€” Asha's token is genuine. The bug is a missing authorization check.
โ†’OWASP calls this BOLA (Broken Object Level Authorization) โ€” #1 on the API Top 10.
โ†’UUIDs instead of 7, 8, 9 make guessing harder but fix nothing โ€” ids leak in links, logs and emails.

6b

One guard for every owned resource

The middleware only needs one question answered: who owns this? Each route passes a function that answers it.

internal/authz/owner.go
// OwnerFunc returns the user id that owns the target.
type OwnerFunc func(r *http.Request) (string, error)

// PathOwner: the {param} in the URL *is* the owner id.
func PathOwner(param string) OwnerFunc {
    return func(r *http.Request) (string, error) {
        return r.PathValue(param), nil
    }
}

// PostOwner: load the post, return who owns it.
func PostOwner(r *http.Request) (string, error) {
    p, ok := posts[r.PathValue("id")]
    if !ok {
        return "", ErrNotFound
    }
    return p.OwnerID, nil
}

func OwnerOrAdmin(owner OwnerFunc,
    next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        c := auth.ClaimsFrom(r.Context())
        id, err := owner(r)
        if err != nil {
            http.Error(w, "not found", 404)
            return
        }
        if c.Subject != id && c.Role != "admin" {
            http.Error(w, "not found", 404)
            return
        }
        next.ServeHTTP(w, r)
    }
    return http.HandlerFunc(fn)
}

// wiring
mux.Handle("PATCH /users/{id}", guard(PathOwner("id"), patchUser))
mux.Handle("PATCH /posts/{id}", guard(PostOwner, editPost))
go run ./demos/06
โ”€โ”€ vulnerable: auth only, no ownership check
asha GET /users/7 โ†’ 200 {"id":"7","name":"Asha","email":"asha@example.com"}
asha GET /users/8 โ†’ 200 {"id":"8","name":"Ravi","email":"ravi@example.com"}
asha GET /users/9 โ†’ 200 {"id":"9","name":"Meera","email":"meera@example.com"}
โ”€โ”€ guarded: OwnerOrAdmin
asha  GET   /users/7 โ†’ 200 {"id":"7","name":"Asha","email":"asha@example.com"}
asha  GET   /users/8 โ†’ 404 not found
asha  PATCH /users/7 โ†’ 200 {"id":"7","name":"Asha K","email":"asha@example.com"}
asha  PATCH /users/8 โ†’ 404 not found
admin PATCH /users/8 โ†’ 200 {"id":"8","name":"Ravi S","email":"ravi@example.com"}
asha  PATCH /posts/3 โ†’ 404 not found
admin PATCH /posts/3 โ†’ 200 post updated
โ†’Compare against c.Subject from the verified token โ€” never an id from the body or a header.
โ†’404, not 403, for someone else's resource โ€” don't confirm that user 8 exists.
โ†’Nested resources (a post, an order) load the owner from the database โ€” the URL alone can't tell you.
โ†’For list endpoints, filter in the query: WHERE owner_id = $1.
HANDS-ON ยท TOPIC 2

Edit own profile โ€” or any, as Admin

  • PATCH /users/{id} wrapped in RequireAuth(OwnerOrAdmin(PathOwner("id"), โ€ฆ)).
  • A user can change only their own name; an admin can change anyone's.
  • Reuse the same guard for PATCH /posts/{id} with a PostOwner loader.
  • Tests: own โ†’ 200, other โ†’ 404, admin โ†’ 200, no token โ†’ 401.

Recap

Session 06 in four lines

1IDOR / BOLA: a valid user reading or writing a row that isn't theirs.
2Every object access checks owner == claims.Subject (or admin).
3One generic guard + a small OwnerFunc per resource type.
4Answer 404 to non-owners; UUIDs are not a substitute for the check.