HIGH bola idorfeathersjsapi keys

Bola Idor in Feathersjs with Api Keys

Bola Idor in Feathersjs with Api Keys — how this specific combination creates or exposes the vulnerability

BOLA (Broken Level of Authorization) / IDOR occurs when an API uses weak or missing ownership checks to allow one subject to access or modify the resources of another. In FeathersJS, this commonly arises when a service relies solely on an API key for authentication but does not enforce record-level ownership or tenant isolation. An API key is a bearer credential; if it is scoped too broadly (for example, a single key shared across multiple users or organizations), any request presenting that key can read or write records it should not access.

Consider a Feathers service for user documents. If the service only checks that an API key is valid and does not scope queries to the key’s associated tenant or user, an attacker can simply change the record ID in the request (e.g., from /documents/123 to /documents/124) and access another user’s data if the key has broad permissions. The vulnerability is not in FeathersJS itself but in how the application configures hooks and the service query logic. Without explicit authorization rules that bind a record to an authenticated subject (such as tenant ID or user ID), the API key becomes a universal token, enabling horizontal IDOR across the dataset.

FeathersJS pipelines typically include authentication and hooks where authorization should be enforced. If the authentication handler attaches an API key to the params object but the service does not use that information to filter results, the unauthenticated or low-assurance attack surface expands. For example, a find query that returns all records in a table will expose every row when an attacker has a valid API key, because the query lacks a $or or $and filter tied to the key’s scope. Even with an API key in place, missing row-level checks mean there is no BOLA protection; the API key merely identifies the caller but does not restrict what they can access.

In multi-tenant setups, a common mistake is to store a tenant identifier in the API key metadata but then neglect to enforce tenant_id equals params.tenant_id in service hooks. An attacker can still request /invoices/456 even when the key is linked to tenant_abc, and if the service does not validate that the invoice’s tenant matches the key’s tenant, the request succeeds. This is a classic IDOR enabled by over-privileged API keys. The risk is compounded when the API key has elevated scopes (for example, read:all or manage:org) and the application does not differentiate between read-only and write actions at the record level.

Real-world attack patterns mirror OWASP API Top 10 A01:2023 broken object level authorization. In a PCI-DSS or SOC2 context, failing to bind API keys to least-privilege scopes can lead to unauthorized data access and compliance findings. The detection by middleBrick would flag this as BOLA/IDOR with severity high, noting that unauthenticated scanning found endpoints where record IDs are predictable and API key usage lacks row-level filters. Remediation focuses on tightening authorization in hooks so that every query is scoped to the authenticated subject, and ensuring API keys are scoped narrowly to the minimum required permissions.

Api Keys-Specific Remediation in Feathersjs — concrete code fixes

Remediation centers on ensuring that API keys do not act as broad bearer tokens and that every service query enforces ownership or tenant scoping. The goal is to bind the key to a subject (user or organization) and make that binding part of the data access logic.

Example 1: Scoped API key with tenant isolation

Assume API keys carry a tenant identifier in their payload. In the authentication hook, attach tenant and scopes to params. Then in the service hook, enforce that any find, get, create, update, or remove operation filters by tenant_id.

// src/hooks/authenticate-api-key.js
module.exports = function authenticateApiKey() {
  return async context => {
    const { headers } = context.params;
    const apiKey = headers['x-api-key'];
    if (!apiKey) {
      throw new Error('Unauthorized');
    }
    // Lookup key metadata (pseudo lookup)
    const keyRecord = await context.app.service('api-keys').get(apiKey, { paginate: false });
    context.params.authInfo = {
      tenantId: keyRecord.tenant_id,
      scopes: keyRecord.scopes || []
    };
    return context;
  };
};
// src/hooks/authorize-tenant.js
module.exports = function authorizeTenant() {
  return async context => {
    const { authInfo } = context.params;
    if (!authInfo || !authInfo.tenantId) {
      throw new Error('Forbidden: missing tenant');
    }
    // Enforce tenant scope on find
    if (context.method === 'find') {
      context.params.query = context.params.query || {};
      context.params.query.$and = [
        { tenant_id: authInfo.tenantId },
        ...(Array.isArray(context.params.query.$and) ? context.params.query.$and : [])
      ];
    }
    // Enforce tenant scope on get
    if (context.method === 'get') {
      const originalGet = context.service.get.bind(context.service);
      context.service.get = async id => {
        const record = await originalGet(id);
        if (record.tenant_id !== authInfo.tenantId) {
          throw new Error('Forbidden: tenant mismatch');
        }
        return record;
      };
    }
    // For create/update/remove, ensure tenant binding
    if (['create', 'update', 'patch', 'remove'].includes(context.method)) {
      const body = context.data || context.params.query;
      if (body && body.tenant_id && body.tenant_id !== authInfo.tenantId) {
        throw new Error('Forbidden: cannot assign foreign tenant');
      }
      // default tenant binding
      if (!body) {
        context.data = context.data || {};
        context.data.tenant_id = authInfo.tenantId;
      }
    }
    return context;
  };
};
// src/services/documents/documents.class.js
const { Service } = require('feathersjs');
class DocumentsService extends Service {
  // Override setup to inject hooks
  setup(app) {
    super.setup(app);
    this.hooks({
      before: {
        all: [app.hooks.authenticateApiKey(), app.hooks.authorizeTenant()],
        find: [validateQuerySchema()], // additional input validation
      },
      after: [],
      error: []
    });
  }
}

In this setup, the API key is used only to derive tenant context. The hooks ensure that every query includes tenant_id=$and filter, so a key cannot read another tenant’s rows. The get override enforces a runtime check in case of direct id calls, preventing IDOR even if the query filter is bypassed.

Example 2: User-bound API keys with ownership checks

For user-specific resources, bind the key to a user ID and enforce ownership on each record operation.

// src/hooks/authenticate-user-key.js
module.exports = function authenticateUserKey() {
  return async context => {
    const key = await context.app.service('api-keys').get(context.params.headers['x-api-key'], { paginate: false });
    context.params.authUser = { userId: key.user_id, scopes: key.scopes || [] };
    return context;
  };
};
// src/hooks/ensure-ownership.js
module.exports = function ensureOwnership() {
  return async context => {
    const { authUser } = context.params;
    if (!authUser || !authUser.userId) {
      throw new Error('Unauthorized');
    }
    // Scope find to owned records
    if (context.method === 'find') {
      context.params.query = {
        $and: [
          { user_id: authUser.userId },
          ...(Array.isArray(context.params.query.$and) ? context.params.query.$and : [])
        ]
      };
    }
    // Ensure get/patch/remove target owned records
    if (['get', 'patch', 'remove'].includes(context.method)) {
      const id = context.id;
      const originalGet = context.service.get.bind(context.service);
      context.service.get = async function patchedGet(_id) {
        const record = await originalGet(_id);
        if (record.user_id !== authUser.userId) {
          throw new Error('Forbidden: not owner');
        }
        return record;
      };
    }
    return context;
  };
};
// src/services/messages/messages.class.js
const { Service } = require('feathersjs');
class MessagesService extends Service {
  setup(app) {
    super.setup(app);
    this.hooks({
      before: {
        all: [app.hooks.authenticateUserKey(), app.hooks.ensureOwnership()],
        find: [validateOwnershipQuery()],
      },
      after: [],
      error: []
    });
  }
}

These examples show concrete patterns: derive subject from the API key, and enforce scoping in before hooks. Combine with input validation to prevent ID manipulation via query parameters. middleBrick will highlight missing tenant or ownership filters as BOLA/IDOR with remediation guidance to add explicit row-level checks tied to the API key’s scope.

Best practices summary

  • Keep API keys narrow: assign tenant or user scope and avoid global read:all unless strictly required.
  • Always filter records by the authenticated subject in service hooks (find, get, count).
  • Validate and sanitize IDs to prevent ID tampering (e.g., ensure param.id is a UUID format and matches the record’s owner).
  • Use distinct keys per integration or per tenant to limit blast radius if a key is exposed.

Related CWEs: bolaAuthorization

CWE IDNameSeverity
CWE-250Execution with Unnecessary Privileges HIGH
CWE-639Insecure Direct Object Reference CRITICAL
CWE-732Incorrect Permission Assignment HIGH

Frequently Asked Questions

How does middleBrick detect BOLA/IDOR when API keys are used?
middleBrick runs unauthenticated scans that observe whether endpoints expose predictable record IDs and whether responses differ across tenants when only an API key is provided. It flags missing tenant/ownership filters as BOLA/IDOR findings.
Can API keys alone be considered secure authorization in FeathersJS?
No. API keys are bearer credentials; they identify but do not enforce record-level permissions. You must add hooks that scope queries to the key’s tenant or user to prevent IDOR.