Broken Access Control in Hapi with Firestore
Broken Access Control in Hapi with Firestore — how this specific combination creates or exposes the vulnerability
Broken Access Control (BAC) in a Hapi service that uses Cloud Firestore typically occurs when authorization checks are missing, incomplete, or bypassed before Firestore operations. Because Firestore security rules alone do not replace application-level authorization, a Hapi route that directly calls Firestore with a user-supplied identifier can allow BOLA/IDOR when the route trusts the identifier without verifying that the requesting user owns or is permitted to access that resource.
Consider a route that retrieves a user profile by profileId from Firestore. If the route uses the authenticated user’s ID only for authentication (e.g., verifying a JWT) but does not enforce ownership or role checks before constructing the Firestore path, an attacker can change profileId to another user’s ID and read or modify data. This is a classic BOLA/IDOR pattern: the access control decision is broken because the application fails to enforce that the authenticated subject can only access their own data.
Firestore indexes and flexible querying can inadvertently widen the attack surface. For example, a query that searches across a collection without scoping to the requesting user’s organization or tenant enables horizontal privilege escalation. If a route builds a query like db.collection('records').where('orgId', '==', orgId) but the route does not validate that the authenticated user belongs to that orgId, an attacker can supply any orgId and enumerate or modify records across organizations.
Additionally, Firestore’s real-time listeners and cached local states can expose stale or overly permissive data if the client-side cache is misconfigured and the server does not revalidate authorization on each request. In Hapi, failure to validate permissions on every request and reliance on client-side state can lead to insecure direct object references when routes use parameters to form Firestore document paths without rechecking ownership or tenant boundaries.
To illustrate, a vulnerable Hapi route might look like this, where authorization is missing:
// Vulnerable: no authorization check on profileId
server.route({
method: 'GET',
path: '/profiles/{profileId}',
handler: async (request, h) => {
const profileId = request.params.profileId;
// No check whether request.auth.credentials.userId matches profileId
const doc = await db.collection('profiles').doc(profileId).get();
if (!doc.exists) {
return h.response({ error: 'Not found' }).code(404);
}
return doc.data();
}
});
An authenticated attacker can request /profiles/other-user-id and obtain another user’s profile data. This demonstrates BAC via insufficient authorization in the application layer, even when Firestore rules exist. The risk is compounded when Firestore rules are permissive for development or when rule logic does not align with the application’s authorization model. In such cases, the application must enforce ownership or role-based checks before any Firestore read or write.
Firestore-Specific Remediation in Hapi — concrete code fixes
Remediation centers on enforcing authorization in the application before any Firestore operation and scoping queries to the requesting user or tenant. Always resolve the authenticated subject (e.g., user ID from session or token) and use it to scope Firestore reads and queries. Avoid using raw user input to form document paths without validation.
For the profile retrieval example, the fix is to compare the authenticated user ID with the requested profile ID and ensure they match:
const { User } = require('firebase-admin');
server.route({
method: 'GET',
path: '/profiles/{profileId}',
handler: async (request, h) => {
const userId = request.auth.credentials.userId; // from JWT/session
const profileId = request.params.profileId;
// Enforce ownership: users can only access their own profiles
if (userId !== profileId) {
return h.response({ error: 'Forbidden' }).code(403);
}
const doc = await db.collection('profiles').doc(profileId).get();
if (!doc.exists) {
return h.response({ error: 'Not found' }).code(404);
}
return doc.data();
}
});
For list or search endpoints, scope queries by the authenticated user’s tenant or organization. Do not rely on the client to filter; validate and use server-side identifiers:
server.route({
method: 'GET',
path: '/records',
handler: async (request, h) => {
const userId = request.auth.credentials.userId;
// Assume each record has an orgId and the user belongs to one org
const userOrg = await db.collection('users').doc(userId).get().then(d => d.data().orgId);
if (!userOrg) {
return h.response({ error: 'Forbidden' }).code(403);
}
// Scope query to the user's organization
const snapshot = await db.collection('records')
.where('orgId', '==', userOrg)
.get();
const records = [];
snapshot.forEach(doc => records.push({ id: doc.id, ...doc.data() }));
return records;
}
});
Use Firestore security rules as a safety net, not the primary authorization mechanism. Align rule conditions with the application’s authorization logic by validating tenant or ownership fields in rules and enforcing them in code. For example, a rule can require that request.auth != null and that request.auth.uid == request.resource.data.userId for writes, while the Hapi route also performs explicit checks. This layered approach reduces the impact of misconfigured rules and ensures robust BAC controls.
Finally, prefer parameterized queries and avoid string concatenation to form document paths. Validate identifiers to ensure they match expected formats before using them in Firestore calls. These practices reduce injection risks and ensure access control decisions remain consistent across the stack.