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 ID | Name | Severity |
|---|---|---|
| CWE-250 | Execution with Unnecessary Privileges | HIGH |
| CWE-639 | Insecure Direct Object Reference | CRITICAL |
| CWE-732 | Incorrect Permission Assignment | HIGH |