Side Channel Attack in Adonisjs with Firestore
Side Channel Attack in Adonisjs with Firestore — how this specific combination creates or exposes the vulnerability
A side channel attack in the AdonisJS + Firestore context exploits timing or behavioral differences in how Firestore operations respond based on secrets or internal state, rather than direct data exfiltration through Firestore APIs. AdonisJS, as a Node.js web framework, often handles authentication, authorization, and data-fetching routines that interact with Firestore. If these routines conditionally access Firestore or vary execution paths based on secrets (e.g., user permissions, existence of a document, or ownership checks), an attacker can infer sensitive information through timing measurements or error behavior.
Consider an endpoint that retrieves a user profile: if the code first checks a local cache or a secondary store to decide whether to query Firestore, and then conditionally performs a Firestore get() based on that check, the presence or absence of a Firestore read can leak whether a user ID is valid. An attacker can measure response times to infer valid user IDs or infer the existence of documents. This is a classic timing side channel. Additionally, Firestore error messages returned by AdonisJS handlers—such as permission denied or not found—can differ in timing or content based on whether the document exists or the user has access, further aiding inference.
Another vector involves Firestore security rules and how AdonisJS enforces authorization. If AdonisJS performs an operation only after a local check (e.g., verifying ownership in application code) and then calls Firestore, versus relying entirely on Firestore rules, the round-trip pattern and rule evaluation timing can vary. For example, a Firestore rule that performs a get() on another collection to validate relationships introduces additional latency that depends on data presence. An attacker can craft requests that trigger different rule paths, observing timing differences to infer relationships or policy states.
Concrete example: an AdonisJS route that fetches a document only if a user’s role matches a condition checked in JS before the Firestore call. The conditional branch introduces timing divergence. A request for an unauthorized document might skip Firestore entirely and return quickly, while an authorized request incurs a Firestore read delay. By averaging response times across many requests, an attacker can infer authorization outcomes. Similarly, Firestore’s index-based query performance can vary subtly with collection size and indexing state, which an attacker might detect when iterating over possible document IDs to map accessible data.
Error handling in AdonisJS also plays a role. If errors from Firestore (e.g., permission-denied versus not-found) are surfaced with different latency or logged differently, these discrepancies become side channels. For instance, a not-found error might short-circuit further processing and return faster than a permission-denied error that triggers additional logging or fallback logic. Consistent error handling and avoiding branching on sensitive conditions in request paths are essential to mitigate this combination.
In summary, the AdonisJS application logic that gates or conditions Firestore interactions, combined with Firestore’s operational characteristics (latency, error responses, rule evaluation), creates measurable timing and behavioral differences. These differences can be leveraged in a side channel attack to infer document existence, permissions, or data relationships without directly reading the target data.
Firestore-Specific Remediation in Adonisjs — concrete code fixes
Remediation focuses on making Firestore interactions consistent in timing and behavior, avoiding branches based on secrets or document existence, and standardizing error handling. In AdonisJS, structure data access layers so that all paths perform the same high-cost operations regardless of secrets or authorization outcomes. Use constant-time operations where possible and ensure errors are handled uniformly.
Example 1: Always query Firestore and use a constant-time merge in JS. Instead of checking ownership first, fetch the document and then apply ownership checks locally with constant-time logic. This ensures a Firestore read occurs on every request, removing timing variance based on permissions.
import { DateTime } from 'luxon';
import UserProfile from 'App/Models/UserProfile';
import { Firestore } from '@google-cloud/firestore';
export default class ProfilesController {
async show({ request, response }) {
const userId = request.param('id');
const firestore = new Firestore();
// Always read the document; avoid early existence checks
const docRef = firestore.collection('profiles').doc(userId);
const doc = await docRef.get();
const data = doc.exists ? doc.data() : null;
// Constant-time merge: apply masking regardless of existence
const result = {
id: userId,
email: data?.email ?? null,
name: data?.name ?? null,
masked: true,
};
return response.json(result);
}
}
Example 2: Use Firestore transactions or batched reads to ensure uniform cost. If you need to validate relationships, include those reads in every path so timing does not depend on conditionals. This example performs a consistent two-collection read pattern.
import { Firestore } from '@google-cloud/firestore';
export default class OrdersController {
async index({ request, response }) {
const firestore = new Firestore();
const userId = request.param('user_id');
// Always read user and orders; no early exit
const userRef = firestore.collection('users').doc(userId);
const ordersRef = firestore.collection('orders').where('userId', '==', userId);
const [userSnap, ordersSnap] = await Promise.all([userRef.get(), ordersSnap]);
if (!userSnap.exists) {
return response.status(404).json({ error: 'not_found' });
}
const orders = ordersSnap.docs.map(d => ({ id: d.id, ...d.data() }));
return response.json({ user: userSnap.data(), orders });
}
}
Example 3: Standardize error responses and avoid leaking distinctions via timing. Map Firestore permission errors to a generic error after a fixed-duration fallback sleep to obscure timing differences. This is a lightweight mitigation and should be combined with proper rule design.
import { Firestore } from '@google-cloud/firestore';
export default class SecureController {
async show({ request, response }) {
const firestore = new Firestore();
const docRef = firestore.collection('sensitive').doc('doc1');
try {
const doc = await docRef.get();
if (!doc.exists) {
return response.status(404).json({ error: 'not_found' });
}
return response.json(doc.data());
} catch (err) {
// Simulate consistent processing time to obscure timing differences
const sleep = (ms) => new Promise(res => setTimeout(res, ms));
await sleep(150); // constant delay
return response.status(500).json({ error: 'internal_error' });
}
}
}
Additionally, review Firestore security rules to ensure they do not introduce variable evaluation paths based on data content that an attacker can probe. Keep rule evaluation predictable and avoid rules that perform conditional reads on other collections based on request parameters. In AdonisJS, centralize authorization logic and ensure it does not gate Firestore calls in a way that changes the operation type or presence of Firestore interactions.