mcpField
A
aegis-authz-access-control

aegis-authz-access-control

github.com/as-fossSecurity16145 installsUpdated 9w ago
Repository

About aegis-authz-access-control

A security-audit skill for reviewing post-authentication authorization decisions in application code. It helps developers identify function, object, attribute, tenant-isolation, and bypass flaws using route-by-route checks.

Key features

  1. 01Checks function-level access controls
  2. 02Traces object and tenant ownership
  3. 03Reviews attribute-level permissions
  4. 04Flags path and header bypasses
  5. 05Examines delegation and impersonation

Use cases

  1. 01Auditing API routes for missing role checks
  2. 02Finding IDOR risks in object lookups
  3. 03Reviewing multi-tenant data isolation
  4. 04Assessing API keys and impersonation scopes

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/as-foss/aegis-skills --skill aegis-authz-access-control

After installing, type/aegis-authz-access-controlin Claude Code to confirm it loaded.

Bundled filesreferences/background.mdreferences/further-reading.md

Details

From the project README

Scope & Objectives

Scan for authorization failures: code decides wrongly who may call an action (BFLA, vertical escalation) and which object instances an action may touch (BOLA/IDOR, horizontal escalation).

In scope:

  • Missing or incorrect role/permission checks on routes, handlers, resolvers, controllers.
  • Object fetches keyed by request-supplied IDs without ownership/tenant predicates.
  • Multi-tenant isolation gaps (tenant context from input, shared caches/queues/indexes).
  • Authorization logic bugs: deny-by-absence, OR-condition typos, verb tampering.
  • Path normalization and proxy-header bypasses (X-Original-URL, X-Forwarded-For trust).
  • Impersonation/API-key/service-trust features without scoping or audit.

Out of scope (other modules): authentication strength, session fixation, injection, SSRF, crypto. Only audit post-authentication decision logic here; flag unauthenticated reachability of privileged actions as BFLA.

Prerequisites & Vocabulary

Zero-background primer: the terms this module uses, one line each. Deeper plain-language explanations for every class live in the repository GUIDE.md glossary.

  • BFLA: reaching an admin-only action because the route never checks your role
  • BOLA / IDOR: changing an identifier in the request (order 1042 → 1043) to touch another user's object
  • horizontal escalation: acting on someone else's data while staying at your own privilege level
  • vertical escalation: doing things reserved for a higher role (ordinary user → admin)
  • tenant: one customer's isolated slice of a shared multi-customer system
  • deny-by-absence: when no explicit allow rule exists, the answer must be deny — a missing check is a finding, not "probably fine"
  • taint flow / source→sink: path untrusted data travels from entry point to dangerous function
  • finding status: Confirmed > Probable > Needs-Review; evidence rules in templates/finding-report.md

Mental Model

Evaluate every route along two independent axes; a route is safe only if BOTH pass.

AxisQuestionFailurePrimary CWE
Function (action)May this subject invoke this endpoint at all?BFLA / vertical escalationCWE-862, CWE-863
Object (instance)Given yes above, may it touch THIS row/file/tenant?BOLA / IDOR / cross-tenant leakCWE-639
AttributeMay it read/write THESE fields on the object?Excessive data exposure, mass assignmentCWE-285

Decision procedure per route:

  1. Identify the subject source: session, verified JWT sub claim, API key, mTLS identity. Anything else (query param, header like user_id, client-stored role) is a red flag by itself.
  2. Identify required roles/actions from business naming (admin, /users, /internal, DELETE).
  3. Locate where the object is fetched and whether the query embeds the subject (WHERE id = ? AND tenant_id = ?).
  4. Locate the decision point (middleware/decorator/annotation/policy) and confirm it runs BEFORE any state change or data return.
  5. Default answer when no explicit allow rule exists: deny. Absence of a check = finding, not "probably fine."

Two-account intuition: every endpoint must behave identically whether the caller owns the object or not — except for the owner, who alone gets data.

What To Check

Function-Level Authorization (BFLA)
  • Enumerate every mutating route (POST/PUT/PATCH/DELETE) and every /admin, /internal, /manage subtree; verify each carries a guard that reads the server-side identity, not a client claim verbatim.
  • Flag "security by obscurity": hidden UI buttons with no server check; SPA routes rendered only for admins while backing endpoints are open; endpoints whose only defense is an unguessable path segment.
  • Check HTTP-method asymmetry: a controller guarded for GET but exposing DELETE/PATCH on the same path via a separate handler or catch-all (app.all('/admin/x', ...) without guard).
  • Compare API versions: v1 module annotated/guarded, duplicated v2 controller forgotten. Diff guard counts per version directory.
  • Test registration/update payloads for role mass assignment ("role":"admin", "isAdmin":true) accepted into the user record.
  • Inspect debug/internal surfaces: Spring Actuator, Django admin + DEBUG views, Flask debugger, Go pprof, ASP.NET detailed errors, GraphQL introspection + mutations in production.
Object-Level Authorization (BOLA/IDOR)
  • Trace each request parameter named id|uuid|guid|uid|email|slug|ref into the first DB/document lookup; require the predicate owner_id == subject.id or tenant_id == subject.tenant_id IN THE QUERY, not after it.
  • Flag collection/list endpoints returning .all() / find() / Model.objects.all() serialized wholesale (mass listing leaks other tenants' rows).
  • Nested resources: for /org/:orgId/doc/:docId, verify BOTH doc.orgId == orgId AND caller membership in orgId; membership check on the parent alone is insufficient.
  • Export/report/download/CSV/PDF/streaming endpoints: these routinely skip guards because they bypass normal serializers. Grep for export|download|report|dump|backup|csv|xlsx.
  • GraphQL: per-resolver object checks (not just a top-level auth directive), especially on nested fields (invoice.customer.address) and DataLoader/batch loaders that fetch by ID arrays.
  • Sequential IDs (AutoField, serial, IDENTITY(1,1), incrementing numeric JSON ids): note as exploitability amplifier; UUIDs are mitigation only, never a substitute for predicates.
  • Property-level granularity: field-level BOLA — record access correct but sensitive columns (salary, role, SSN-class fields) serialized to callers lacking need-to-know; diff serializer output per caller role.
  • Mass assignment crossover: client-writable privileged fields ("role":"admin") are property-level authorization failure too; cross-ref the API module's mass-assignment class when spotted here.
Vertical & Horizontal Escalation
  • Roles trusted from client storage: JWT claims (role, isAdmin, permissions) accepted without re-deriving from the authoritative store on sensitive calls; localStorage.getItem('role') gating UI-only.
  • Forced browsing to admin panels: static admin bundles served publicly (express.static('admin')), framework admin at default paths with weak/no auth.
  • Version-skew escalation: legacy /api/v1/admin/* kept alive after v2 hardening; old microservice copies of a moved endpoint still deployed.
  • Cross-tenant context: tenant_id taken from request body/query/subdomain instead of the authenticated principal's record; Host-based tenancy spoofable via direct IP access or Host header override.
  • Shared infrastructure keys: cache keys lacking tenant prefix (cache.get("invoice:"+id)), global search indexes, queue messages consumed without tenant re-check, temp files named by raw ID in shared dirs.
Logic, Header & Path Bypasses
  • Allow vs deny style: find checks written as blocklists (if (role === 'banned') return) — invert them mentally; new roles inherit access silently.
  • Boolean typos: role === 'admin' || userId === id granting admins everywhere is correct, but role !== 'admin' || userId !== id inside negations, or &&/|| swaps, produce accidental allows. Flag any compound condition mixing role equality and identity equality.
  • Path normalization: routing decisions made on raw URLs before decode → %2f, //, /./, trailing dots, ;jsessionid, double encoding (%252f) splitting guard path from handler path.
  • Verb tampering: guard attached per-method; try HEAD/OPTIONS/TRACE or method overrides (X-HTTP-Method-Override: DELETE, _method=DELETE form param).
  • Proxy headers honored by app code: X-Original-URL, X-Rewrite-URL, X-Forwarded-Host, X-Forwarded-Proto used in routing/access decisions; X-Forwarded-For parsed for IP allowlists (req.headers['x-forwarded-for'].split(',')[0]) — spoofable unless app sits behind a trusted proxy configured to strip/set it.
  • Framework-specific: ASP.NET [AllowAnonymous] on a controller overriding global fallback; Laravel $this->middleware() missing in constructor; Rails skip_before_action :authenticate_user!.
Delegation & Impersonation
  • Support/admin impersonation ("login as customer"): verify scope limits (time-boxed, reason captured), immutable audit log entry with actor+target+reason, termination on password change, and no token minting that drops the original actor identity.
  • API keys / PATs inheriting the full interactive-user permission set including admin roles; check key scopes exist and are enforced server-side.
  • Service-to-service: internal endpoints trusting a forwarded username/email header set by "some gateway" (X-User-Email) with no signature verification; mTLS absent on internal hops assumed.
  • OAuth token exchange: scope downgrades ignored; refresh tokens carrying original broad scopes after admin revocation.

Where To Look

StackRoute tableGuard markers (present = good)Files/dirs
Express/Fastify/NestJSapp.use/router.stack, Nest @Controller metadata, Fastify printRoutesrequireAuth, passport.authenticate, addHook('preHandler'), @UseGuards(...), @Roles(...)routes/, controllers/, *.controller.ts, main.ts
Django/DRFurls.py, router.register, django-extensions show_urlsLoginRequiredMixin, PermissionRequiredMixin, permission_classes, DRF get_queryset filter*/urls.py, views.py, viewsets.py, serializers.py
Flask/FastAPIdecorators @app.route/@get/@post, OpenAPI specbefore_request, @login_required, `Depends(current_userrequire_roles), router-level dependencies=[...]`
Spring (Java/Kotlin)@RequestMapping tree, Actuator /mappings (guarded!)@PreAuthorize, @Secured, @RolesAllowed, SecurityFilterChain matchers, @EnableMethodSecurity**/controller/**, SecurityConfig.*, application.yml
ASP.NET CoreControllers + minimal-API Map* chain[Authorize], [Authorize(Policy=...)], .RequireAuthorization(), FallbackPolicy, [AllowAnonymous] (audit each)Controllers/, Program.cs, Startup.cs
Laravel/Symfonyphp artisan route:list, bin/console debug:routerauth: middleware, can: middleware, policies, Gate::authorize, Symfony voters + access_control YAMLroutes/web.php, routes/api.php, app/Policies, config/packages/security.yaml
Railsrails routesbefore_action :authenticate_user!, Pundit authorize @record / after_action :verify_authorized, CanCanCan load_and_authorize_resourceconfig/routes.rb, app/controllers/**, app/policies/**
Go (net/http/chi/gin)mux registrations, chi walkmiddleware wrappers RequireAuth/RequireRole, gin.Use(...), group-scoped r.Group(func(r){ r.Use(guard)})cmd/*/main.go, internal/http/**, middleware/

Also inspect: docker-compose.yml/k8s manifests for internal-only ports (context, not proof), OpenAPI/Swagger specs vs implemented routes (spec drift = undocumented live routes), seeders/fixtures revealing role names, frontend JS bundles for hidden admin routes (/static/js/*.js contains "/admin" strings).

Patterns & Signatures

Route inventory (run first; diff counts against guard markers):

rg -n "@(Get|Post|Put|Patch|Delete|Request)Mapping\(" --type java --type kt
rg -n "@app\.(route|get|post|put|patch|delete)|@(get|post|put|delete)\(" --type py
rg -n "\.(get|post|put|patch|delete|all)\(\s*['\"][^'\"]+" -t ts -t js
rg -n "Route::(get|post|put|patch|delete|any|resource|apiResource)" --type php
rg -n "(map|Map)(Get|Post|Put|Delete|Patch)\(\"" --type cs
rg -n "HandleFunc|\.GET\(|\.POST\(|\.DELETE\(|\.PUT\(" --type go

Guard-presence markers:

@(PreAuthorize|Secured|RolesAllowed|PostAuthorize)\(
(LoginRequiredMixin|PermissionRequiredMixin|UserPassesTestMixin)
(@UseGuards|@Roles\(|SetMetadata\(['"](roles|permissions))

Missing-guard smell (Express: handler as 2nd arg, no middleware between):

\.(get|post|put|patch|delete)\(\s*['"][^'"]*['"]\s*,\s*(async\s*)?\(?[A-Za-z_$]

Object-fetch-without-subject heuristics:

objects\.(get|filter)\((pk|id)=
findById\((req|params|pathVariable)[^)]*\)
Model\.objects\.all\(\)

Client-trusted identity signals:

(decoded|payload|claims|token)\.(role|isAdmin|is_admin|admin|permissions)
localStorage\.(getItem\(['"]?(role|is_admin|isAdmin|user)['"]?\))
(req\.body|request\.body|payload)\.(role|isAdmin|is_admin|account_type)

Header/path bypass primitives:

(?i)x-(original-url|rewrite-url|forwarded-for|forwarded-host|http-method-override)
headers\[['"](x-forwarded-for|x-real-ip)['"]\]
(_method|X-HTTP-Method-Override|method_override)

Impersonation & delegation:

(impersonat(e|ion)|acting_as|act_as|as_user|become_user|sudo_mode)
(api[_-]?key|personal_access_token).{0,40}(scopes?|abilities?)

Debug/internal exposure:

(/debug/pprof|/actuator|/_profiler|/__profiler|/telescope|/horizon|DEBUG\s*=\s*True)

Tenant-context-from-input:

(tenant_id|org_id|organization_id)['"]?\s*[:=]\s*(req\.(query|params|body)|request\.(GET|data|json))

Taint Tracing Guidance

Sources (untrusted until proven otherwise): all route params, query strings, bodies, JWT payload claims other than the verified sub, headers (X-*, even Authorization API keys), subdomain/Host, message-queue payloads.

Sinks (authorization-relevant): ORM/document queries (findOne/findById/get/filter/Query.first), raw SQL, file reads/writes/downloads, cache get/put, queue publish/consume, admin RPC calls, email/export generation containing other users' data.

Procedure:

  1. For each sink fetching a singleton, list its arguments. Flag any lookup helper whose signature lacks a user/tenant/principal parameter — e.g., getInvoice(id) vs getInvoice(id, actor); the omission predicts BOLA upstream of any later check.
  2. Walk backward from sink to source: if the predicate chain from param → WHERE clause does not include the authenticated principal attribute, mark BOLA candidate.
  3. Track role/permission values: if the value used in the decision originates from a source (JWT body, DB column writable via profile update, Redis session field set from login-time client data) rather than the canonical user store, mark escalation candidate.
  4. For caches/queues, trace key construction and consumer-side filtering; a tenant tag on write but no filter on read is still vulnerable.
  5. Sanitizers do NOT exist for authz — a validated integer ID is still a valid other-tenant ID. Never treat validation as authorization.

Framework shortcuts: DRF get_queryset() overrides (absence = base manager queryset = leak); Rails ApplicationRecord default scopes (none by default); SQLAlchemy session.query(Model).get(id) ignores any Python-level tenant notion unless filters added; JPA EntityManager.find(Cls, id) never tenant-filters without Hibernate @TenantId/@Filter.

Exploitation & Reproduction

Two-Account BOLA Test (canonical sequence)
  1. Register victim and attacker accounts:
    curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"V1ctimPass!"}'
    curl -s -X POST https://target/api/register -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"Att4ckerPass!"}'
    
  2. Authenticate both; store tokens:
    VICTIM=$(curl -s -X POST https://target/api/login -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"V1ctimPass!"}' | jq -r .token)
    ATTACKER=$(curl -s -X POST https://target/api/login -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"Att4ckerPass!"}' | jq -r .token)
    
  3. Victim creates an object; capture its ID:
    curl -s -X POST https://target/api/invoices -H "Authorization: Bearer $VICTIM" \
      -H 'Content-Type: application/json' -d '{"amount":100,"memo":"secret-payroll"}'
    # -> {"id":1042,...}   (sequential => enumerate 1030..1050 next)
    
  4. Attacker requests the victim's object:
    curl -i https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER"
    
  5. Interpret (this triage matters):
    ResultMeaning
    200 + victim's fields ("secret-payroll")Confirmed BOLA (CWE-639)
    200 + empty/redacted body but different timing than random IDsExistence oracle; partial protection
    403Function reachable, object denied — check OTHER verbs (curl -i -X PUT/PATCH/DELETE) and nested paths before clearing
    404 for foreign IDs, 200 for ownCorrect anti-enumeration masking — verify owner still receives full record
  6. Repeat for write paths (swap IDs on PATCH/DELETE), collections (GET /api/invoices without ?mine=1), exports (GET /api/invoices/export.csv), and nested forms (GET /org/A/invoices/1042 where 1042 belongs to org B).
  7. Static-only confirmation (no test accounts available): locate the handler for the route, show the fetch statement has no ownership predicate, and show no policy/middleware wraps the route — cite file:line for both halves.
BFLA Admin-Endpoint Test
  1. As ATTACKER (normal user), call an administrative mutation:
    curl -i -X POST https://target/api/users -H "Authorization: Bearer $ATTACKER" \
      -H 'Content-Type: application/json' -d '{"email":"[email protected]","password":"Pwn3dPass!","role":"admin"}'
    
  2. Outcomes: 201/200 = confirmed BFLA (+ possible privilege self-grant); 403 = guarded; 401 = route also misses authentication (worse).
  3. Role-injection variant at registration/update:
    curl -s -o /dev/null -w '%{http_code}\n' -X POST https://target/api/register \
      -H 'Content-Type: application/json' \
      -d '{"email":"[email protected]","password":"Esc4late!","role":"admin","isAdmin":true}'
    # then re-login as [email protected] and retry step 1
    
  4. Method-swap variant against GET-guarded routes: curl -i -X DELETE https://target/admin/users/42 -H "Authorization: Bearer $ATTACKER".
Path/Header/Verb Bypass Tests
# Path confusion (use --path-as-is so curl does not normalize)
for p in '/admin/users' '/admin//users' '/admin/./users' '/admin%2fusers' '/adm%69n/users' \
         '/admin;/users' '/admin/users/' '/ADMIN/users' '/api/v1/admin/stats'; do
  curl -s -o /dev/null -w "%{http_code} $p\n" --path-as-is "https://target$p" -H "Authorization: Bearer $ATTACKER"
done
# Double-encoding round trip: %252f decodes once to %2f, again to '/'
# Proxy-header injection
curl -s -o /dev/null -w '%{http_code}\n' 'https://target/public/health' -H 'X-Original-URL: /admin/users'
curl -s -o /dev/null -w '%{http_code}\n' 'https://target/admin/stats'  -H 'X-Forwarded-For: 10.0.0.1'
# Verb tampering incl. overrides
for m in HEAD OPTIONS TRACE PATCH PUT; do curl -s -o /dev/null -w "%{http_code} $m\n" -X $m https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER"; done
curl -s -X POST https://target/api/invoices/1042 -H "Authorization: Bearer $ATTACKER" \
  -H 'X-HTTP-Method-Override: DELETE' -d '_method=DELETE'

Expected observable outcome: any status other than uniform 403/404 across all variants (and identical behavior with a valid-but-unprivileged token) demonstrates a guard-bypass primitive; record which exact byte-sequence bypassed it.

Remediation

Centralize the Policy Layer

Move every decision into ONE choke point per stack; handlers may refine but never be the sole guard:

StackChoke point
Express/FastifyRouter-level middleware registered before branch handlers (guardRouter.use(requireAuth, attachPolicy))
NestJSGlobal APP_GUARD JwtAuthGuard + RolesGuard; per-handler @Roles() metadata
FastAPIdependencies=[Depends(require_auth)] on the router; policy dependency per operation
DjangoMixins on every CBV + DRF DEFAULT_PERMISSION_CLASSES + custom get_queryset base class
Flaskbefore_request blueprint hook + decorator on every view
SpringSecurityFilterChain path rules + @EnableMethodSecurity + @PreAuthorize default-deny meta-annotation
ASP.NET CoreFallbackPolicy = RequireAuthenticatedUser globally; [Authorize(Policy)] per area
LaravelRoute middleware groups (auth:sanctum) + Policies auto-discovery; $gate->deny default
Symfonyaccess_control YAML + voters invoked via isGranted in a base controller/subscriber
Railsbefore_action :authenticate_user! in ApplicationController + Pundit verify_authorized after_action raising on skipped checks
GoSingle func Authz(next http.Handler) http.Handler wrapper composed onto every non-public route group
Before / After Recipes

Express:

// VULNERABLE
router.get('/invoices/:id', async (req, res) => {
  const inv = await Invoice.findByPk(req.params.id);
  res.json(inv);
});

// FIXED
const { requireAuth } = require('./middleware/auth');
const { authorize } = require('./policy');            // centralized decision engine
const { scopeToTenant } = require('./middleware/tenant');

router.get('/invoices/:id', requireAuth, authorize('invoice:read'),
  scopeToTenant, async (req, res) => {
    const inv = await Invoice.findOne({               // predicate lives IN the query
      where: { id: req.params.id, tenantId: req.user.tenantId }
    });
    if (!inv) return res.sendStatus(404);             // uniform mask, no existence oracle
    res.json(inv);
  });

Django/DRF:

# VULNERABLE
class InvoiceViewSet(viewsets.ModelViewSet):
    queryset = Invoice.objects.all()
    serializer_class = InvoiceSerializer

# FIXED
class InvoiceViewSet(viewsets.ModelViewSet):
    serializer_class = InvoiceSerializer
    permission_classes = [IsAuthenticated, TenantMemberRequired]

    def get_queryset(self):                            # object axis enforced centrally
        return Invoice.objects.filter(
            tenant_id=self.request.user.tenant_id,
        )
# FIXED (classic Django CBV equivalent)
class InvoiceDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
    model = Invoice
    permission_required = 'invoices.view_invoice'

    def get_queryset(self):
        return Invoice.objects.filter(tenant=self.request.user.tenant)

Spring:

// VULNERABLE
@GetMapping("/api/v2/invoices/{id}")
public InvoiceDto get(@PathVariable long id) {
    return invoices.findById(id).map(mapper::toDto)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

// FIXED
@PreAuthorize("hasAuthority('invoice:read')")
@GetMapping("/api/v2/invoices/{id}")
public InvoiceDto get(@PathVariable long id, @AuthenticationPrincipal AppUser user) {
    return invoices.findByIdAndTenantId(id, user.getTenantId())
        .map(mapper::toDto)
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

Laravel:

// VULNERABLE
Route::get('/invoices/{invoice}', [InvoiceController::class, 'show']);

// FIXED
Route::middleware(['auth:sanctum'])
    ->get('/invoices/{invoice}', [InvoiceController::class, 'show'])
    ->middleware('can:view,invoice');                 // policy = single decision point
// app/Policies/InvoicePolicy.php  (registered automatically by name convention)
class InvoicePolicy
{
    public function view(User $user, Invoice $invoice): bool
    {
        return $user->tenant_id === $invoice->tenant_id;
    }
}

Rails (Pundit):

# VULNERABLE
def show
  @invoice = Invoice.find(params[:id])
end

# FIXED
def show
  @invoice = Invoice.find(params[:id])
  authorize @invoice                                   # raises unless policy permits
end
# with_scope in policy keeps index endpoints tenant-safe:
class ApplicationPolicy
  class Scope
    def resolve
      scope.where(tenant_id: user.tenant_id)
    end
  end
end
Deny-by-Default Routing
// Express: public surface is an EXPLICIT allowlist; everything else falls through the guard wall
app.use(['/health', '/live', '/ready', '/login', '/register'], publicRouter);
app.use(requireAuth);                       // wall: nothing below resolves without identity
app.use('/admin', requireRole('admin'));    // vertical wall for whole subtree
app.use('/api', apiRouter);                 // apiRouter assumes authenticated ctx
# Spring Security: authenticate everything, permit only listed public paths
security:
  config:
    http:
      authorizeHttpRequests:
        - "/health"
        - "/login"
      others: "authenticated"
      admin-prefix: "/admin/**"
      admin-role: "ROLE_ADMIN"
    method-security: true

Equivalent defaults: ASP.NET FallbackPolicy (see below), Symfony last-resort - { path: ^/, roles: ROLE_USER }, Laravel auth on the api group, Rails authenticate in ApplicationController with skip_before_action audited per use.

// ASP.NET Core: fail closed
builder.Services.AddAuthorization(o =>
{
    o.FallbackPolicy = new AuthorizationPolicyBuilder()
        .RequireAuthenticatedUser().Build();
});
// minimal APIs: app.MapGet("/x", h).RequireAuthorization("TenantMember");
Tenant Scoping & Row-Level Security
  • Enforce tenancy in ONE layer beneath all queries: repository base classes, DRF get_queryset, EF Core global query filters (HasQueryFilter(i => i.TenantId == _tenant.Id)), Hibernate @Filter/@TenantId, SQLAlchemy events, or Postgres RLS as backstop:
-- Postgres row-level security as defense-in-depth under app-level checks
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON invoices
  USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- per request/connection: SET LOCAL app.tenant_id = '<uuid-from-session>';
  • Name cache keys and queue headers with tenant scope (t:{tenant}:invoice:{id}); re-validate tenant on consume.
  • Replace sequential integers with opaque UUIDv4 for external references (mitigation, not fix).
  • Impersonation: dedicated short-TTL token type carrying act (actor) + target, reason string persisted to append-only audit table, auto-expiry on target password change, admin-only grant of the impersonation permission itself.

Verification & Validation

Automated Tests (GIVEN/WHEN/THEN)
Scenario: Owner retains access (negative test against over-blocking)
  GIVEN user A authenticated and owning invoice 1042
  WHEN A requests GET /api/invoices/1042
  THEN status is 200 AND body contains A's memo value

Scenario: Non-owner denied on read
  GIVEN users A and B on distinct tenants
  WHEN B requests GET /api/invoices/1042
  THEN status is 403 or 404 AND body contains no A-owned field values

Scenario: Non-owner denied on write
  WHEN B sends PATCH /api/invoices/1042 {"amount": 1}
  THEN status is 403/404 AND a follow-up GET by A shows unchanged amount

Scenario: Role matrix cell denial
  GIVEN role "viewer" exists with read-only rights
  WHEN viewer calls DELETE /api/invoices/1042
  THEN status is 403 regardless of ownership

Scenario: Unauthenticated denial
  WHEN request omits credentials entirely
  THEN status is 401 for every non-public route

Run the matrix for EVERY route × EVERY role × {own, foreign, nonexistent} object ID; assert status codes, not just absence of error text.

Route-Coverage Test (deny-by-default assertion)
// pseudocode — port enumerator per framework (Express stack walk, rails routes, artisan route:list)
const PUBLIC_ROUTES = new Set(['/health', '/live', '/ready', '/login', '/register', '/docs', '/openapi.json']);

test('every non-public route declares >=1 authz guard; no public mutations', () => {
  for (const r of enumerateRoutes(app)) {
    const isPublic = PUBLIC_ROUTES.has(r.path);
    const guards = r.middleware.filter(isAuthzGuard); // requireAuth/authorize/roles...
    expect(guards.length >= 1 || isPublic).toBe(true);           // deny-by-default
    if (isPublic) expect(r.methods.some(m => ['POST','PUT','PATCH','DELETE'].includes(m))).toBe(false);
  }
});

CI companion: snapshot-test the route table (rails routes, artisan route:list, Nest route dump) and fail review when count grows without a paired guard change.

Manual Re-test Checklist
  1. Re-run two-account sequence from Exploitation section; confirm 403/404 and owner 200.
  2. Re-run all path/header/verb bypass loops; confirm uniform denial.
  3. Confirm admin can STILL operate (fixes frequently over-block; test each role-matrix cell positively).
  4. Confirm export/download and GraphQL nested-field paths now enforce ownership.
  5. Confirm impersonation writes audit rows and expires correctly.
  6. Confirm cache keys are tenant-prefixed and stale pre-fix entries purged (flush affected namespaces).
Post-Fix Greps
rg -n "skip_authorization|SkipAuthorization" app/
rg -n "objects\.(get|filter)\((pk|id)=" --type py
rg -n "findById\(" --type java --type kt | rg -v "AndTenant|ForTenant|Scoped"
rg -ni "x-(original-url|rewrite-url)" .
rg -n "x-forwarded-for" -i . | rg -v "trust proxy|trustedProxies|rate.?limit"
rg -n "@(Get|Post|Put|Delete|Patch)Mapping\(" --type java --type kt | wc -l   # compare vs @PreAuthorize count

Severity Assessment

Finding archetypeExampleBaseline CVSS v3.1 vectorScore band
Unauthenticated admin actionPOST /api/users reachable pre-authCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:HCritical (9.8)
Vertical escalation, authenticated writenormal user creates/disables usersCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:HHigh (8.8)
Cross-tenant bulk readlisting returns all tenants' records (scope-changing)CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:NHigh (7.7)
Horizontal write, single objectattacker edits another tenant's invoiceCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:NMedium–High (6.5)
Single-record cross-tenant readone foreign record via guessed ID, high-sensitivity dataCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NMedium (6.5)
Existence/timing oracle only200-empty vs 404 differentiationCVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:NLow–Medium (3.1–4.3)

Rubric modifiers: enumerable sequential IDs or absent rate limits push one band up; secrets/PHI/financial fields push up; compensating network ACLs on genuinely internal-only services push down but never to informational while the code defect persists. Admin takeover ≥9.0 always Critical; cross-tenant read defaults High even for one record when data is personal or confidential.

Primary CWE mapping: missing guard entirely → CWE-862; guard present but wrong → CWE-863; user-controlled key to another's object → CWE-639; umbrella → CWE-285; shared cache/queue granularity → CWE-1220.

Common False Positives

  • Gateway/sidecar enforcement: bare handlers behind an authorizing API gateway, service mesh AuthorizationPolicy, or mTLS-spiffe mesh. Verify the enforcement point exists and covers the route before dismissing; document it in the report.
  • Intentionally public endpoints: pricing catalogs, status pages, docs — confirm product intent and absence of mutation methods.
  • UUID capability references: unguessable IDs reduce practical exploitability, but the missing ownership predicate remains a real defect — report as lower-severity hardening gap, not a false positive of the pattern itself.
  • Deliberate 404-masking: uniform 404 for foreign objects with 200 for owners is correct design, not a leak — confirm response timing/body are indistinguishable for nonexistent vs foreign IDs.
  • Dev/test-only routes compiled out of production builds (verify build flags/env gates actually exclude them, e.g., if (process.env.NODE_ENV !== 'production')).
  • Internal tools behind VPN + mTLS where identity is established at network layer — residual risk note only if the same service also binds a public interface.
  • Read endpoints already filtered at the serializer to public fields — confirm no private field slips through relations/nested serializers.

References

Comments

Markdown supported