Access Control Remediation
Risk Severity
๐ด Critical Fix Effort
๐๏ธ High (Significant Work) Est. Time
โฑ๏ธ 4-8 hours Reference
A01:2021 CWE-639
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.
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
Never trust that a user should have access to a resource just because they know its ID.
Always verify ownership or permissions server-side.
Vulnerable vs Secure Pattern
python
# โ 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
python
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)
python
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.
python
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
javascript
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
sql
-- 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])Common Mistakes to Avoid
โ Client-Side Only Checks
javascript
// BAD: Only hiding UI elements
{user.isAdmin && <AdminPanel />}
// Attacker can still call:
// POST /api/admin/deleteUserAlways enforce on the server, UI hiding is just UX.
โ Trusting User Input for Authorization
python
# BAD: Getting user_id from request body
@app.route('/api/profile', methods=['PUT'])
def update_profile():
user_id = request.json.get('user_id') # Attacker controls this!
User.query.get(user_id).update(request.json)
# GOOD: Use authenticated session
@app.route('/api/profile', methods=['PUT'])
def update_profile():
current_user.update(request.json) # From session, not requestโ Inconsistent Enforcement
javascript
// BAD: Protected GET but not DELETE
app.get('/api/docs/:id', authorize, getDoc);
app.delete('/api/docs/:id', deleteDoc); // Missing authorize!
// GOOD: Consistent middleware
router.use('/api/docs', authorize);
router.get('/:id', getDoc);
router.delete('/:id', deleteDoc);๐งช Testing Verification
python
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 unchanged๐งช Testing Verification
IDOR Test Cases
bash
# 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
bash
# 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
/consoleโ ๏ธ Common Mistakes
โ Client-Side Only Checks
javascript
// DON'T: Only hide in UI
if (user.isAdmin) {
showAdminButton();
}
// Endpoint still accessible!Attackers can call endpoints directly
โ Correct Approach
python
# DO: Server-side authorization
@require_role('admin')
def admin_endpoint():
# Decorator checks role on serverAlways enforce on server side
โ Trusting User ID from Request
python
# 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
python
# 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