Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ This project follows semantic versioning for the published packages. Sample,
documentation, and CI-only changes may remain unreleased until the next package
release is useful for users.

## Unreleased

### `@authlock/core`

- **Added per-dimension identity `normalize`** — a `Record<dimension, (value) =>
string>` on the policy, applied to each identity value before key derivation
on every path (check / record / reset). Without it, `Alice`, `alice`, and
`alice ` hash to three different counters, so a case-insensitive login could
be brute-forced past the limit by varying case or whitespace — a bypass the
docs previously only warned about. Normalizers are validated at construction
(each entry must be a function). Fully backward compatible: omit `normalize`
and behaviour is unchanged.

## 0.3.1

`@nest-native/lockout` only (`@authlock/core` unchanged at 0.3.0).
Expand Down
15 changes: 12 additions & 3 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,18 @@ how long any single run can keep an identity locked, at `windowMs`.
single-target brute force even when the IP is spoofable.
- **Don't `whitelist` on a spoofable dimension.** A whitelist keyed on an IP an
attacker can forge is a bypass; key it on something they can't control.
- **Normalize identity dimensions.** `Alice` and `alice` (and unicode variants)
hash to different counters. If your auth is case-insensitive, lowercase /
canonicalize the username before you pass it, or the limit is per-spelling.
- **Normalize identity dimensions.** `Alice`, `alice`, and `alice ` hash to
three different counters, so on a case-insensitive login an attacker bypasses
the limit by varying case or whitespace. Configure a per-dimension
`normalize` map (applied before hashing, on every path — check / record /
reset) — or normalize in your own extractor:
```ts
new LockoutManager({
parameters: [['username'], ['ip']],
normalize: { username: (v) => v.trim().toLowerCase() }, // ip left verbatim
// ...
});
```
- **Bound store growth.** Every distinct identity creates a record, so a flood
of fabricated usernames/IPs grows the store. Schedule `pruneExpired()` (it
drops records past their window, capping the store to identities active within
Expand Down
15 changes: 15 additions & 0 deletions packages/core/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export interface Identifiers {
*/
export type LockoutParameter = ReadonlyArray<string>;

/**
* Per-dimension value normalizers, applied to each identity value BEFORE it is
* hashed into a key. This is a security control, not a convenience: without it,
* `Alice`, `alice`, and `alice ` derive three different counters, so an attacker
* bypasses the limit by varying case or whitespace on a case-insensitive login.
* Normalize the dimensions your auth treats as equal (e.g. lowercase + trim the
* username), and leave the rest (an IP needs no normalization) unlisted.
*/
export type Normalize = Readonly<Record<string, (value: string) => string>>;

/** A persisted failure counter for one resolved key. */
export interface FailureRecord {
/** Canonical, collision-resistant key derived from (parameter, values). */
Expand Down Expand Up @@ -98,6 +108,11 @@ export interface LockoutPolicy {
tiers?: readonly CooloffTier[];
/** Which dimension combinations are evaluated; a lock trips if ANY of them does. */
parameters: readonly LockoutParameter[];
/**
* Per-dimension value normalizers applied before key derivation — the
* defence against case/whitespace lockout bypass. See {@link Normalize}.
*/
normalize?: Normalize;
/** Identities for which locking is skipped entirely (never counted or locked). */
whitelist?: (id: Identifiers) => boolean | Promise<boolean>;
/** Clear a key's failures on a successful login. Defaults to `true`. */
Expand Down
12 changes: 10 additions & 2 deletions packages/core/key.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto';

import type { Identifiers, LockoutParameter } from './interfaces';
import type { Identifiers, LockoutParameter, Normalize } from './interfaces';

/** A configured parameter paired with the concrete key it resolves to. */
export interface DerivedKey {
Expand All @@ -22,10 +22,17 @@ export interface DerivedKey {
* than concatenated so a credential-bearing dimension never appears verbatim in
* a store, and so arbitrary user input can never collide with or forge another
* key by embedding the separator.
*
* A per-dimension `normalize` map (if given) is applied to each value before it
* is hashed, so equal-by-policy identities (`Alice` vs `alice`) collapse to one
* counter — the defence against case/whitespace lockout bypass. It is applied
* uniformly on every path (check / record / reset) because all of them route
* through here.
*/
export function deriveKeys(
id: Identifiers,
parameters: readonly LockoutParameter[],
normalize?: Normalize,
): DerivedKey[] {
const derived: DerivedKey[] = [];
for (const parameter of parameters) {
Expand All @@ -37,7 +44,8 @@ export function deriveKeys(
applies = false;
break;
}
pairs.push([dimension, value]);
const normalizer = normalize?.[dimension];
pairs.push([dimension, normalizer ? normalizer(value) : value]);
}
if (!applies) {
continue;
Expand Down
28 changes: 25 additions & 3 deletions packages/core/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
LockoutManagerOptions,
LockoutParameter,
LockoutStore,
Normalize,
} from './interfaces';

/**
Expand Down Expand Up @@ -63,6 +64,24 @@ function validateTiers(cooloffMs: number, tiers?: readonly CooloffTier[]): void
}
}

/**
* Reject a `normalize` map whose entries are not functions — a non-function
* normalizer would throw (or silently no-op) inside key derivation, on the hot
* auth path, long after startup. Fail loud instead.
*/
function validateNormalize(normalize?: Normalize): void {
if (normalize === undefined) {
return;
}
for (const [dimension, fn] of Object.entries(normalize)) {
if (typeof fn !== 'function') {
throw new TypeError(
`LockoutManager: normalize['${dimension}'] must be a function.`,
);
}
}
}

function notLocked(): LockoutDecision {
return { locked: false, retryAfterMs: null };
}
Expand Down Expand Up @@ -112,6 +131,7 @@ function mostRestrictive(
export class LockoutManager {
private readonly store: LockoutStore;
private readonly parameters: readonly LockoutParameter[];
private readonly normalize?: Normalize;
private readonly limit: number;
private readonly cooloffMs: number;
private readonly windowMs: number;
Expand All @@ -135,8 +155,10 @@ export class LockoutManager {
throw new TypeError('LockoutManager: `cooloffMs` must be greater than 0.');
}
validateTiers(options.cooloffMs, options.tiers);
validateNormalize(options.normalize);
this.store = options.store;
this.parameters = options.parameters;
this.normalize = options.normalize;
this.limit = options.limit;
this.cooloffMs = options.cooloffMs;
this.windowMs = effectiveWindowMs(
Expand Down Expand Up @@ -169,7 +191,7 @@ export class LockoutManager {
}
const now = this.now();
let decision = notLocked();
for (const { parameter, key } of deriveKeys(id, this.parameters)) {
for (const { parameter, key } of deriveKeys(id, this.parameters, this.normalize)) {
const evaluation = await this.readKey(key, now);
decision = mostRestrictive(decision, toDecision(evaluation, parameter));
}
Expand All @@ -187,7 +209,7 @@ export class LockoutManager {
}
const now = this.now();
let decision = notLocked();
for (const { parameter, key } of deriveKeys(id, this.parameters)) {
for (const { parameter, key } of deriveKeys(id, this.parameters, this.normalize)) {
const keyDecision = await this.applyFailure(id, parameter, key, now);
decision = mostRestrictive(decision, keyDecision);
}
Expand Down Expand Up @@ -219,7 +241,7 @@ export class LockoutManager {
}

private async clearKeys(id: Identifiers): Promise<void> {
for (const { key } of deriveKeys(id, this.parameters)) {
for (const { key } of deriveKeys(id, this.parameters, this.normalize)) {
try {
await this.store.clear(key);
} catch (error) {
Expand Down
45 changes: 45 additions & 0 deletions packages/core/test/key.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,49 @@ describe('deriveKeys', () => {
assert.notEqual(a, b);
assert.notEqual(a, c);
});

describe('normalize', () => {
const lower = { username: (v: string) => v.trim().toLowerCase() };

it('collapses case/whitespace variants to a single key (the bypass defence)', () => {
const canonical = deriveKeys({ username: 'alice' }, [['username']], lower)[0].key;
for (const variant of ['Alice', 'ALICE', ' alice ', 'aLiCe']) {
const [derived] = deriveKeys({ username: variant }, [['username']], lower);
assert.equal(derived.key, canonical, `${variant} must normalize to alice`);
}
// And it equals hashing the normalized value directly.
assert.equal(canonical, expectedKey([['username', 'alice']]));
});

it('only normalizes listed dimensions, leaving others verbatim', () => {
const [byUser, byIp] = deriveKeys(
{ username: 'BOB', ip: '1.2.3.4' },
[['username'], ['ip']],
lower,
);
assert.equal(byUser.key, expectedKey([['username', 'bob']]));
assert.equal(byIp.key, expectedKey([['ip', '1.2.3.4']]), 'ip untouched');
});

it('normalizes each dimension of a combination parameter independently', () => {
const [derived] = deriveKeys(
{ username: 'CAROL', ip: '1.2.3.4' },
[['username', 'ip']],
{ username: (v: string) => v.toLowerCase() },
);
assert.equal(
derived.key,
expectedKey([
['username', 'carol'],
['ip', '1.2.3.4'],
]),
);
});

it('is a no-op when no normalizer is given for a dimension', () => {
const withEmptyMap = deriveKeys({ username: 'Dave' }, [['username']], {})[0].key;
const without = deriveKeys({ username: 'Dave' }, [['username']])[0].key;
assert.equal(withEmptyMap, without);
});
});
});
48 changes: 48 additions & 0 deletions packages/core/test/manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,54 @@ describe('LockoutManager — configuration validation', () => {
]),
);
});

it('rejects a normalize entry that is not a function', () => {
assert.throws(
() =>
new LockoutManager({
store,
limit: 2,
cooloffMs: 1000,
parameters: [['username']],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
normalize: { username: 'nope' as any },
}),
/normalize\['username'\] must be a function/,
);
});
});

describe('LockoutManager — identity normalization', () => {
it('counts case/whitespace variants against ONE counter (bypass defence)', async () => {
const manager = new LockoutManager({
store: new InMemoryLockoutStore(),
limit: 3,
cooloffMs: 1000,
parameters: [['username']],
normalize: { username: (v) => v.trim().toLowerCase() },
});
// Three "different" spellings of the same user must share the counter and
// trip the lock — without normalization each spelling would be limit-1.
assert.equal((await manager.recordFailure({ username: 'Alice' })).locked, false);
assert.equal((await manager.recordFailure({ username: 'ALICE ' })).locked, false);
assert.equal((await manager.recordFailure({ username: ' alice' })).locked, true);
// And check() sees the lock under any spelling.
assert.equal((await manager.check({ username: 'aLiCe' })).locked, true);
});

it('normalizes the reset path too (unlock by any spelling)', async () => {
const manager = new LockoutManager({
store: new InMemoryLockoutStore(),
limit: 2,
cooloffMs: 1000,
parameters: [['username']],
normalize: { username: (v) => v.toLowerCase() },
});
await manager.recordFailure({ username: 'Bob' });
assert.equal((await manager.recordFailure({ username: 'bob' })).locked, true);
await manager.reset({ username: 'BOB' }); // different spelling still unlocks
assert.equal((await manager.check({ username: 'bob' })).locked, false);
});
});

describe('LockoutManager — multi-key evaluation', () => {
Expand Down
18 changes: 18 additions & 0 deletions packages/nestjs/test/lockout.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ describe('LockoutModule.forRoot', () => {
await moduleRef.close();
});

it('threads a core `normalize` option through to the manager', async () => {
// The adapter passes options straight to LockoutManager, so a per-dimension
// normalizer configured here must collapse case end-to-end.
const moduleRef = await Test.createTestingModule({
imports: [
LockoutModule.forRoot({
...baseOptions(),
normalize: { username: (v) => v.toLowerCase() },
}),
],
}).compile();

const service = moduleRef.get(LockoutService);
assert.equal((await service.reportFailure({ username: 'Bob' })).locked, false);
assert.equal((await service.reportFailure({ username: 'bob' })).locked, true);
await moduleRef.close();
});

it('reset() administratively unlocks even with resetOnSuccess disabled', async () => {
const moduleRef = await Test.createTestingModule({
imports: [
Expand Down
9 changes: 6 additions & 3 deletions website/docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ title: API Reference
| `windowMs?` | failure-counting window; defaults to the effective cooloff |
| `tiers?` | escalating cooloff by failure count, e.g. `[{atFailures: 10, cooloffMs: 3_600_000}]` |
| `parameters` | dimension combinations to evaluate; a lock trips if **any** trips |
| `normalize?` | `Record<string, (value) => string>` — per-dimension normalizers applied before hashing (case/whitespace-bypass defence) |
| `whitelist?` | `(id) => boolean \| Promise<boolean>` — identities never counted or locked |
| `resetOnSuccess?` | clear failures on success (default `true`) |
| `failMode?` | `'open'` (default) or `'closed'` |
Expand Down Expand Up @@ -98,9 +99,11 @@ proxy headers for you.
trust. Because a lock trips if **any** parameter trips, a `['username']`
parameter still catches a single-target brute force even when the IP is
spoofable. And never `whitelist` on a dimension an attacker can forge.
- **Normalize identity dimensions.** `Alice` and `alice` (and unicode variants)
hash to different counters. If your auth is case-insensitive, lowercase /
canonicalize the username before passing it, or the limit is per-spelling.
- **Normalize identity dimensions.** `Alice`, `alice`, and `alice ` hash to
three different counters — on a case-insensitive login an attacker bypasses
the limit by varying case or whitespace. Set a per-dimension `normalize` map
(e.g. `{ username: (v) => v.trim().toLowerCase() }`); it is applied before
hashing on every path (check / record / reset). Or normalize in your extractor.
- **Bound store growth.** Every distinct identity creates a record. Schedule
`pruneExpired()` (it drops records past their window, capping the store to
identities active within `windowMs`), put a rate limiter
Expand Down