Access Control Remediation
Broken Access Control is the #1 vulnerability in the OWASP Top 10. It occurs when users can act outside their intended permissions, leading to unauthorized data access, modification, or privilege escalation.
Access Control Decision Flow
Common Vulnerabilities
Horizontal Privilege Escalation
- • IDOR:
/api/users/123→/api/users/456 - • Parameter manipulation:
user_id=other - • Missing ownership validation
- • Predictable resource identifiers
Vertical Privilege Escalation
- • Accessing admin endpoints without auth
- • Role parameter tampering
- • Forced browsing to restricted URLs
- • Missing function-level access control
IDOR Prevention
Always Verify Ownership
Vulnerable vs Secure Pattern
# ❌ VULNERABLE - No ownership check
@app.route('/api/documents/<int:doc_id>')
def get_document(doc_id):
document = Document.query.get_or_404(doc_id)
return jsonify(document.to_dict())
# ✅ SECURE - Ownership verification
@app.route('/api/documents/<int:doc_id>')
@login_required
def get_document(doc_id):
document = Document.query.filter_by(
id=doc_id,
owner_id=current_user.id # Verify ownership
).first_or_404()
return jsonify(document.to_dict())
# ✅ SECURE - With shared access support
@app.route('/api/documents/<int:doc_id>')
@login_required
def get_document(doc_id):
document = Document.query.get_or_404(doc_id)
# Check if user has any form of access
if not has_document_access(current_user, document):
abort(403)
return jsonify(document.to_dict())
def has_document_access(user, document):
"""Check if user can access document"""
# Owner always has access
if document.owner_id == user.id:
return True
# Check if document is shared with user
share = DocumentShare.query.filter_by(
document_id=document.id,
user_id=user.id
).first()
return share is not NoneUse UUIDs Instead of Sequential IDs
import uuid
from sqlalchemy.dialects.postgresql import UUID
class Document(db.Model):
# Use UUID instead of auto-increment
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
owner_id = db.Column(UUID(as_uuid=True), db.ForeignKey('user.id'), nullable=False)
title = db.Column(db.String(255))
# URL would be: /documents/550e8400-e29b-41d4-a716-446655440000
# Not easily guessable like /documents/123
# Still verify ownership even with UUIDs!
# UUIDs add obscurity but are NOT a security controlRole-Based Access Control (RBAC)
from functools import wraps
from flask import abort
# Define permissions
class Permission:
READ = 'read'
WRITE = 'write'
DELETE = 'delete'
ADMIN = 'admin'
# Role definitions
ROLE_PERMISSIONS = {
'viewer': [Permission.READ],
'editor': [Permission.READ, Permission.WRITE],
'admin': [Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN],
}
def requires_permission(permission):
"""Decorator to check permissions"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated:
abort(401)
user_permissions = ROLE_PERMISSIONS.get(current_user.role, [])
if permission not in user_permissions:
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
# Usage
@app.route('/api/users', methods=['DELETE'])
@requires_permission(Permission.ADMIN)
def delete_user():
# Only admins can reach here
pass
@app.route('/api/documents', methods=['POST'])
@requires_permission(Permission.WRITE)
def create_document():
# Editors and admins can create
passAttribute-Based Access Control (ABAC)
ABAC provides more granular control by evaluating attributes of the user, resource, action, and environment.
from dataclasses import dataclass
from typing import Any, Dict
@dataclass
class AccessRequest:
subject: Dict[str, Any] # User attributes
resource: Dict[str, Any] # Resource attributes
action: str # Requested action
environment: Dict[str, Any] = None # Context (time, IP, etc.)
class PolicyEngine:
def evaluate(self, request: AccessRequest) -> bool:
"""Evaluate access request against policies"""
# Policy 1: Users can only access resources in their department
if request.resource.get('department') != request.subject.get('department'):
# Exception for admins
if 'admin' not in request.subject.get('roles', []):
return False
# Policy 2: Sensitive documents require manager role
if request.resource.get('classification') == 'sensitive':
if 'manager' not in request.subject.get('roles', []):
return False
# Policy 3: Write access only during business hours
if request.action in ['write', 'delete']:
hour = request.environment.get('hour', 12)
if hour < 9 or hour > 17:
if 'admin' not in request.subject.get('roles', []):
return False
# Policy 4: Contractors cannot delete
if request.action == 'delete':
if request.subject.get('user_type') == 'contractor':
return False
return True
# Usage
policy = PolicyEngine()
def check_access(user, resource, action):
request = AccessRequest(
subject={
'id': user.id,
'department': user.department,
'roles': user.roles,
'user_type': user.user_type,
},
resource={
'id': resource.id,
'department': resource.department,
'classification': resource.classification,
},
action=action,
environment={
'hour': datetime.now().hour,
'ip': request.remote_addr,
}
)
return policy.evaluate(request)API Authorization Patterns
Express.js Middleware
const express = require('express');
// Authorization middleware
const authorize = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
};
// Resource ownership middleware
const ownsResource = (resourceGetter) => {
return async (req, res, next) => {
const resource = await resourceGetter(req);
if (!resource) {
return res.status(404).json({ error: 'Resource not found' });
}
// Admin bypass
if (req.user.role === 'admin') {
req.resource = resource;
return next();
}
// Ownership check
if (resource.ownerId !== req.user.id) {
return res.status(403).json({ error: 'Access denied' });
}
req.resource = resource;
next();
};
};
// Usage
app.get('/api/admin/users',
authorize('admin'),
adminController.listUsers
);
app.get('/api/documents/:id',
authorize('user', 'admin'),
ownsResource(req => Document.findById(req.params.id)),
documentController.getDocument
);
app.delete('/api/documents/:id',
authorize('user', 'admin'),
ownsResource(req => Document.findById(req.params.id)),
documentController.deleteDocument
);Database-Level Controls
-- PostgreSQL Row-Level Security (RLS)
-- Enable RLS on table
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Policy: Users can only see their own documents
CREATE POLICY documents_owner_policy ON documents
FOR ALL
USING (owner_id = current_setting('app.current_user_id')::uuid);
-- Policy: Admins can see all documents
CREATE POLICY documents_admin_policy ON documents
FOR ALL
USING (
EXISTS (
SELECT 1 FROM users
WHERE id = current_setting('app.current_user_id')::uuid
AND role = 'admin'
)
);
-- Policy: Shared documents
CREATE POLICY documents_shared_policy ON documents
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM document_shares
WHERE document_id = documents.id
AND user_id = current_setting('app.current_user_id')::uuid
)
);
-- Set user context in application
-- Python/psycopg2:
-- cursor.execute("SET app.current_user_id = %s", [user_id])Testing Verification
import pytest
class TestDocumentAccessControl:
"""Test IDOR and access control vulnerabilities"""
def test_user_cannot_access_others_document(self, client):
"""Horizontal privilege escalation test"""
# User A creates document
user_a = create_user()
doc = create_document(owner=user_a)
# User B tries to access
user_b = create_user()
client.login(user_b)
response = client.get(f'/api/documents/{doc.id}')
assert response.status_code == 403
def test_user_cannot_delete_others_document(self, client):
"""Test IDOR on destructive actions"""
user_a = create_user()
doc = create_document(owner=user_a)
user_b = create_user()
client.login(user_b)
response = client.delete(f'/api/documents/{doc.id}')
assert response.status_code == 403
assert Document.query.get(doc.id) is not None # Not deleted
def test_regular_user_cannot_access_admin_endpoint(self, client):
"""Vertical privilege escalation test"""
user = create_user(role='user')
client.login(user)
response = client.get('/api/admin/users')
assert response.status_code == 403
def test_parameter_tampering_blocked(self, client):
"""Test role parameter tampering"""
user = create_user(role='user')
client.login(user)
# Try to elevate own role
response = client.put('/api/profile', json={'role': 'admin'})
user.refresh()
assert user.role == 'user' # Role unchangedManual Testing
IDOR Test Cases
# Horizontal privilege escalation tests
# Login as user A, try to access user B's resources
# Test 1: Direct object reference
GET /api/users/123/profile # Your ID
GET /api/users/456/profile # Other user's ID - should fail
# Test 2: Sequential ID enumeration
for id in {1..1000}; do
curl -H "Cookie: session=your_session" \
https://api.site.com/documents/$id
done
# Should only return your documents
# Test 3: UUID guessing (if predictable)
GET /api/orders/550e8400-e29b-41d4-a716-446655440000Vertical Privilege Escalation Tests
# Test admin endpoints as regular user
curl -X GET https://api.site.com/admin/users \
-H "Cookie: session=regular_user_session"
# Expected: 403 Forbidden
# Test role manipulation
curl -X PUT https://api.site.com/api/profile \
-H "Cookie: session=regular_user_session" \
-d '{"role": "admin"}'
# Expected: Role field should be ignored
# Test hidden admin paths
/admin
/admin.php
/administrator
/manage
/consoleCommon Mistakes
❌ Client-Side Only Checks
// DON'T: Only hide in UI
if (user.isAdmin) {
showAdminButton();
}
// Endpoint still accessible!Attackers can call endpoints directly
✅ Correct Approach
# DO: Server-side authorization
@require_role('admin')
def admin_endpoint():
# Decorator checks role on serverAlways enforce on server side
❌ Trusting User ID from Request
# DON'T: User controls the ID
user_id = request.args['user_id']
return get_user_data(user_id)Attacker changes user_id parameter
✅ Correct Approach
# DO: Get user ID from session
user_id = session['user_id']
return get_user_data(user_id)Session data is server-controlled
Verification Checklist
- ☐ All endpoints require authentication (except public resources)
- ☐ Authorization checked on every request (not just UI)
- ☐ Resource ownership verified before access
- ☐ Role/permission checks implemented consistently
- ☐ User IDs taken from session, not request parameters
- ☐ Direct object references validated
- ☐ Admin functions protected by role checks
- ☐ API and web endpoints have matching controls
- ☐ Deny by default policy implemented
- ☐ Access control failures logged and monitored
- ☐ Automated tests for access control in place
- ☐ Rate limiting on authentication endpoints
Access Control Labs
Practice exploiting and fixing broken access control.
Evidence to Capture
Paired requests: The request made as the rightful owner and the same request replayed with a second low-privilege account, both with full headers and bodies. The pair is the finding; a single request proves nothing.
Object map:
The identifiers you tried and what each returned — /api/users/123 against /api/users/456, an id taken from an export, another tenant id — with the one that returned real data highlighted.
Function map: The privileged endpoint called with the low-privilege session, with the response body proving data or action rather than an empty 200 shell.
State change read back: For writes, the resource re-read as the victim after the forged update, so the impact is a stored change rather than a status code.
Where the check is missing: The route, controller or resolver that performs the lookup without an ownership or role predicate, quoted from source where available; otherwise the endpoint and the role matrix gap.
Likely False Positives
- Empty 200 with an owner-scoped query: A response of null or an empty list after filtering by owner is the control working. Read the body and the query log, not the status code.
- Two accounts in the same tenant: Horizontal escalation needs a boundary that should hold. Shared-tenant access may be by design, so confirm the tenant model in the data before reporting.
-
Unguessable id that still resolves:
A UUID returning another user data to any authenticated caller is still broken access control, and a
404on a guessed id is not proof of safety. Test with an id you legitimately obtained. - UI hiding rather than authorising: Hiding an admin menu is not a control, but a 403 from the data call is. Identify which layer enforces and report the layer that does not.
- Cached response from another user: A CDN or proxy serving a previous body is a cache-key defect. Report it separately from authorisation.
Testing Checklist
Discovery
- ☐ Build a role by endpoint matrix from the route table and from the API calls the UI makes, including admin, legacy and hidden routes.
- ☐ Include object-level operations — read, update, delete, export, share — not just listing endpoints.
- ☐ Note where the identity used for the check comes from: session, a client-supplied user id, or a JWT claim the server trusts without verifying.
Confirmation
- ☐ With two accounts per role, swap object ids, tenant ids and role parameters, and change the HTTP method to reach the same handler.
- ☐ Repeat unauthenticated, with an expired token, and with a token from a deactivated account.
- ☐ For GraphQL, request fields and mutations that the UI never asks for, and test nested object access.
Impact
- ☐ Prove access to another user or tenant data of a known kind: PII, funds, invoices, admin action. Quantify how many objects are reachable.
- ☐ Distinguish read from write, since a forged write that persists raises severity.
- ☐ Check whether the leaked data can be enumerated in bulk, because the report reads differently for one record than for the whole table.
Remediation
Structural fix
- • Authorise on the server for every object access, deriving the subject from the verified session and never from a parameter the client controls.
- • Centralise the check in repository or policy middleware so a new route cannot be added without it, and make the default deny.
- • Scope database queries by tenant or owner in the query itself, rather than fetching then comparing in application code that a later refactor can drop.
Compensating control
- • Database row-level security as a second enforcement point, so an application bug does not expose other tenants.
- • Unguessable identifiers reduce enumerability but are not authorisation; pair them with the check rather than instead of it.
- • Authorisation tests in CI for every route and role, and logging of access denials for detection.
CWE References: CWE-639