Unicode Normalization in Feathersjs with Basic Auth
Unicode Normalization in Feathersjs with Basic Auth — how this specific combination creates or exposes the vulnerability
Unicode normalization inconsistencies can create authentication bypass risks when Feathersjs applications use Basic Auth. Different Unicode representations of the same logical characters (e.g., Latin capital letter A with acute, U+00C1, versus A + combining acute, U+0041 U+0301) may normalize to different byte sequences depending on how strings are handled by libraries, databases, or the runtime. If a Feathersjs service uses Basic Auth and compares credentials without normalizing both the client-supplied credentials and the stored credentials, an attacker can supply a specially crafted Unicode string that compares as equal to a normalized password without actually matching it byte-for-byte.
In Feathersjs, authentication plugins often hook into the before hook of a service to validate credentials. If this validation decodes and compares the Authorization header without normalizing the username and password, an attacker can exploit canonicalization differences. For example, a normalized username stored as U+00C1 might not match the normalized form of the same character sent in a Basic Auth header, leading to inconsistent comparisons. This can allow an account to be accessed when the attacker’s Unicode input normalizes to the expected form after server-side processing, despite not matching the stored credential exactly.
Additionally, if user-controlled input such as usernames or passwords containing Unicode sequences is logged or reflected in error messages, it can contribute to information disclosure or facilitate further attacks like injection. The combination of Feathersjs hooks, Basic Auth’s base64-encoded credentials decoded server-side, and inconsistent normalization across libraries increases the likelihood of bypassing intended access controls. This is particularly relevant when Feathersjs apps rely on databases or ORMs that may store text in a different normalization form than the runtime uses.
The risk is compounded when Feathersjs services expose user enumeration via timing differences or error messages, because an attacker can iteratively probe normalization variants to discover valid credentials. Although Basic Auth transmits credentials with each request, the vulnerability lies in how the server processes and compares these credentials rather than in the transport layer.
Basic Auth-Specific Remediation in Feathersjs — concrete code fixes
To secure Feathersjs applications using Basic Auth, normalize both the stored credentials and the runtime input before comparison. Use a canonical Unicode normalization form (NFC or NFD) consistently across storage, authentication logic, and any user-provided data. Below are concrete code examples that demonstrate how to implement this remediation.
Example 1: Normalizing credentials in an authentication hook
const { normalize } = require('unorm');
module.exports = function () {
const app = this;
app.hooks.before.push({
name: 'normalize-basic-auth',
hook: function (context) {
const { username, password } = context.params.headers.authorization || '';
if (username && password) {
// Decode Basic Auth credentials if provided as base64
const decoded = Buffer.from(authHeader.split(' ')[1], 'base64').toString('utf8');
const [rawUser, rawPass] = decoded.split(':');
context.params.headers['x-normalized-user'] = normalize('NFC', rawUser);
context.params.headers['x-normalized-pass'] = normalize('NFC', rawPass);
}
return context;
}
});
};
Example 2: Using normalized credentials in a custom authentication handler
const feathers = require('@feathersjs/feathers');
const express = require('@feathersjs/express');
const basicAuth = require('feathers-authentication').strategies.basic;
const { normalize } = require('unorm');
const app = express(feathers());
app.configure(express.rest());
app.configure(express.json());
app.configure(express.urlencoded({ extended: true }));
app.use('/authentication', {
async create(data, params) {
const authHeader = params.headers.authorization;
if (!authHeader || !authHeader.startsWith('Basic ')) {
throw new Error('Unauthorized');
}
const decoded = Buffer.from(authHeader.split(' ')[1], 'base64').toString('utf8');
const [user, pass] = decoded.split(':');
const normalizedUser = normalize('NFC', user);
const normalizedPass = normalize('NFC', pass);
// Compare against stored, pre-normalized credentials
const storedUser = normalize('NFC', 'admin');
const storedPass = normalize('NFC', 's3cur3P@ss');
if (normalizedUser === storedUser && normalizedPass === storedPass) {
return { accessToken: 'mock-token', user: { id: 1, username: normalizedUser } };
}
throw new Error('Invalid credentials');
}
});
Example 3: Applying normalization globally via middleware
const { normalize } = require('unorm');
app.use((req, res, next) => {
const auth = req.headers.authorization;
if (auth && auth.startsWith('Basic ')) {
try {
const decoded = Buffer.from(auth.split(' ')[1], 'base64').toString('utf8');
const [user, pass] = decoded.split(':');
req.headers['x-normalized-user'] = normalize('NFC', user);
req.headers['x-normalized-pass'] = normalize('NFC', pass);
} catch (err) {
// malformed header; proceed without normalized values
}
}
next();
});
// In your authentication hook or service, use req.headers['x-normalized-user']
// and req.headers['x-normalized-pass'] for comparisons.
In addition to normalization, ensure that stored credentials are normalized at the time of user creation or password update. Store normalized forms in your database so that comparisons are deterministic. Also consider using libraries to avoid timing attacks when comparing credentials, and avoid exposing user enumeration through error messages.