Permission Boundary Testing: Privilege Escalation and Least-Privilege Verification

Permission Boundary Testing: Privilege Escalation and Least-Privilege Verification

Privilege escalation occurs when a user gains access or capabilities beyond what they were granted. Vertical escalation is gaining a higher role (user → admin); horizontal escalation is accessing another user's resources at the same privilege level. Both are critical security vulnerabilities that automated testing must catch.

This guide covers testing privilege escalation prevention: vertical escalation vectors, horizontal escalation via IDOR, least-privilege verification, and automated permission matrix testing.

Types of Privilege Escalation

Vertical escalation: User performs admin actions without having admin role.

Horizontal escalation: User A accesses User B's data (same role, different scope).

Temporary escalation abuse: User uses a time-limited elevated token after it should expire.

Parameter tampering: User sends ?admin=true or modifies request body to claim elevated privileges.

Mass assignment: User sends extra fields that update role or permissions directly.

Testing Vertical Privilege Escalation

// tests/security/vertical-escalation.test.ts
import request from 'supertest';
import app from '~/app';

describe('Vertical privilege escalation prevention', () => {
  let userToken: string;
  let editorToken: string;
  let adminToken: string;

  beforeAll(async () => {
    userToken = await loginAs('user@example.com', 'user-role');
    editorToken = await loginAs('editor@example.com', 'editor-role');
    adminToken = await loginAs('admin@example.com', 'admin-role');
  });

  // Admin-only actions that lower-privileged users must not perform
  const adminOnlyEndpoints = [
    { method: 'GET', path: '/api/admin/users', description: 'list all users' },
    { method: 'POST', path: '/api/admin/users', description: 'create user' },
    { method: 'DELETE', path: '/api/admin/users/1', description: 'delete user' },
    { method: 'PUT', path: '/api/admin/settings', description: 'update system settings' },
    { method: 'GET', path: '/api/admin/audit-log', description: 'view audit log' },
    { method: 'POST', path: '/api/admin/impersonate/1', description: 'impersonate user' },
  ];

  for (const endpoint of adminOnlyEndpoints) {
    it(`user cannot ${endpoint.description}`, async () => {
      const res = await request(app)
        [endpoint.method.toLowerCase()](endpoint.path)
        .set('Authorization', `Bearer ${userToken}`);
      
      expect(res.status).toBe(403);
    });

    it(`editor cannot ${endpoint.description}`, async () => {
      const res = await request(app)
        [endpoint.method.toLowerCase()](endpoint.path)
        .set('Authorization', `Bearer ${editorToken}`);
      
      expect(res.status).toBe(403);
    });

    it(`admin CAN ${endpoint.description}`, async () => {
      const res = await request(app)
        [endpoint.method.toLowerCase()](endpoint.path)
        .set('Authorization', `Bearer ${adminToken}`);
      
      // Admin should get 200 or 201, not 403
      expect(res.status).not.toBe(403);
    });
  }
});

Testing Role Self-Assignment

A common vulnerability: can a user update their own role?

describe('Role self-assignment prevention', () => {
  it('user cannot update their own role to admin', async () => {
    const res = await request(app)
      .patch('/api/users/me')
      .set('Authorization', `Bearer ${userToken}`)
      .send({ role: 'admin' });
    
    // Either reject the request or silently ignore the role field
    if (res.status === 200) {
      // If the update succeeded, verify role was NOT changed
      const profileRes = await request(app)
        .get('/api/users/me')
        .set('Authorization', `Bearer ${userToken}`);
      
      expect(profileRes.body.role).toBe('user'); // Not 'admin'
    } else {
      expect(res.status).toBeOneOf([400, 403, 422]);
    }
  });

  it('editor cannot grant admin access to another user', async () => {
    const res = await request(app)
      .patch('/api/users/other-user-id')
      .set('Authorization', `Bearer ${editorToken}`)
      .send({ role: 'admin' });
    
    expect(res.status).toBe(403);
  });

  it('admin cannot self-escalate to super_admin', async () => {
    const res = await request(app)
      .patch('/api/users/me')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({ role: 'super_admin' });
    
    if (res.status === 200) {
      const profile = await getProfile(adminToken);
      expect(profile.role).toBe('admin'); // Unchanged
    } else {
      expect(res.status).toBeOneOf([400, 403, 422]);
    }
  });
});

Testing Parameter Tampering

describe('Parameter tampering prevention', () => {
  it('ignores admin query parameter', async () => {
    const res = await request(app)
      .get('/api/admin/users?admin=true&role=admin')
      .set('Authorization', `Bearer ${userToken}`);
    
    expect(res.status).toBe(403); // admin=true in query ignored
  });

  it('ignores elevated role in request body', async () => {
    const res = await request(app)
      .post('/api/documents')
      .set('Authorization', `Bearer ${userToken}`)
      .send({
        title: 'Test Document',
        authorRole: 'admin',  // Attempted privilege claim
        permissions: ['delete', 'admin'],
      });
    
    if (res.status === 201) {
      // Document was created — verify it doesn't have elevated permissions
      expect(res.body.authorRole).not.toBe('admin');
      expect(res.body.permissions).not.toContain('admin');
    }
  });

  it('does not trust user-supplied tenant_id', async () => {
    // User provides a different tenant's ID in the request body
    const res = await request(app)
      .post('/api/documents')
      .set('Authorization', `Bearer ${userToken}`)
      .send({
        title: 'Cross-tenant document',
        tenantId: 'other-tenant-id',  // Attempted cross-tenant injection
      });
    
    if (res.status === 201) {
      // Document must be in the authenticated user's tenant, not the supplied one
      const doc = await getDocument(adminToken, res.body.id);
      expect(doc.tenantId).not.toBe('other-tenant-id');
    }
  });
});

Testing Mass Assignment Prevention

describe('Mass assignment prevention', () => {
  it('cannot mass-assign protected fields on user creation', async () => {
    const res = await request(app)
      .post('/api/users')
      .set('Authorization', `Bearer ${adminToken}`)
      .send({
        email: 'new-user@example.com',
        password: 'password123',
        // Protected fields that should be ignored:
        role: 'super_admin',
        isVerified: true,
        createdAt: '2020-01-01',
        passwordHash: '$2b$10$fakehash',
      });
    
    expect(res.status).toBeOneOf([200, 201]);
    
    // Verify protected fields were not set
    const user = res.body;
    expect(user.role).not.toBe('super_admin');
    expect(user.isVerified).toBe(false); // Should require email verification
    expect(user.passwordHash).toBeUndefined(); // Never expose in response
  });

  it('cannot mass-assign subscription status', async () => {
    const res = await request(app)
      .put('/api/users/me/profile')
      .set('Authorization', `Bearer ${userToken}`)
      .send({
        displayName: 'Alice',
        subscriptionStatus: 'active',  // Should be ignored
        plan: 'enterprise',            // Should be ignored
        creditsRemaining: 99999,       // Should be ignored
      });
    
    expect(res.status).toBeOneOf([200, 204]);
    
    const profile = await getProfile(userToken);
    expect(profile.subscriptionStatus).not.toBe('active');
    expect(profile.plan).not.toBe('enterprise');
    expect(profile.creditsRemaining).not.toBe(99999);
  });
});

Least-Privilege Verification: Testing Minimal Access

Verify that roles only have the permissions they need — not more:

describe('Least-privilege verification', () => {
  // Editor should have these permissions
  const EDITOR_ALLOWED = [
    'GET /api/documents',
    'POST /api/documents',
    'PUT /api/documents/1',
    'GET /api/users/me',
  ];
  
  // Editor should NOT have these
  const EDITOR_DENIED = [
    'DELETE /api/documents/1',
    'GET /api/admin/users',
    'DELETE /api/users/1',
    'PUT /api/admin/settings',
  ];

  for (const endpoint of EDITOR_DENIED) {
    const [method, path] = endpoint.split(' ');
    
    it(`editor is denied: ${method} ${path}`, async () => {
      const res = await request(app)
        [method.toLowerCase()](path)
        .set('Authorization', `Bearer ${editorToken}`);
      
      expect(res.status).toBe(403);
    });
  }

  // Verify editor isn't over-permissioned — they don't have DELETE even though they can PUT
  it('editor cannot delete resources they can update', async () => {
    const createRes = await request(app)
      .post('/api/documents')
      .set('Authorization', `Bearer ${editorToken}`)
      .send({ title: 'Test Doc' });
    
    expect(createRes.status).toBe(201);
    
    const deleteRes = await request(app)
      .delete(`/api/documents/${createRes.body.id}`)
      .set('Authorization', `Bearer ${editorToken}`);
    
    expect(deleteRes.status).toBe(403);
  });
});

Automated Escalation Matrix

Generate comprehensive escalation tests from your permission model:

import { ROLES, PERMISSIONS, hasPermission } from '~/auth/permissions';

const API_MAP: Record<string, { method: string; path: string }> = {
  'content:delete': { method: 'DELETE', path: '/api/content/test-id' },
  'users:update-role': { method: 'PATCH', path: '/api/users/test-id/role' },
  'billing:modify': { method: 'POST', path: '/api/billing/refund' },
};

describe('Privilege escalation matrix', () => {
  const roles = Object.values(ROLES);
  const permissions = Object.keys(API_MAP) as (keyof typeof API_MAP)[];

  for (const permission of permissions) {
    const endpoint = API_MAP[permission];

    for (const role of roles) {
      const shouldAllow = hasPermission(role as any, permission as any);

      it(`${role}${permission}: ${shouldAllow ? 'ALLOWED' : 'DENIED'}`, async () => {
        const token = role === 'anonymous' ? null : await getTokenForRole(role);
        
        const req = request(app)[endpoint.method.toLowerCase()](endpoint.path);
        if (token) req.set('Authorization', `Bearer ${token}`);

        const res = await req;
        
        if (shouldAllow) {
          expect(res.status).not.toBe(403);
        } else if (!token) {
          expect(res.status).toBe(401);
        } else {
          expect(res.status).toBe(403);
        }
      });
    }
  }
});

Summary

Privilege escalation testing:

  • Test vertical escalation — users cannot perform admin actions by trying admin endpoints
  • Test role self-assignment — users cannot update their own role through the API
  • Test parameter tampering?admin=true, role: 'admin' in body must be ignored
  • Test mass assignment — protected fields (role, subscriptionStatus) must not be settable via bulk updates
  • Verify least privilege — roles have only the permissions they need, no more
  • Generate escalation matrices — systematic coverage of all role × permission combinations

Privilege escalation tests are security tests, not functionality tests. Run them on every PR that touches authorization code, and in nightly security scans against production-equivalent environments.

Read more