mirror of
https://github.com/Unleash/unleash.git
synced 2025-03-27 00:19:39 +01:00
chore: cache query misses to protect against DDoS (#6771)
## About the changes This PR establishes a simple yet effective mechanism to avoid DDoS against our DB while also protecting against memory leaks. This will enable us to release the flag `queryMissingTokens` to make our token validation consistent across different nodes --------- Co-authored-by: Nuno Góis <github@nunogois.com>
This commit is contained in:
parent
d466f608c2
commit
d7ab8863f0
src
lib/services
test
@ -11,7 +11,7 @@ import {
|
||||
API_TOKEN_DELETED,
|
||||
API_TOKEN_UPDATED,
|
||||
} from '../types';
|
||||
import { addDays } from 'date-fns';
|
||||
import { addDays, minutesToMilliseconds } from 'date-fns';
|
||||
import EventService from '../features/events/event-service';
|
||||
import FakeFeatureTagStore from '../../test/fixtures/fake-feature-tag-store';
|
||||
import { createFakeEventsService } from '../../lib/features';
|
||||
@ -243,4 +243,33 @@ describe('When token is added by another instance', () => {
|
||||
expect(found).toBeDefined();
|
||||
expect(found?.username).toBe(token.tokenName);
|
||||
});
|
||||
|
||||
test('should query the db only once for invalid tokens', async () => {
|
||||
jest.useFakeTimers();
|
||||
const { apiTokenService, apiTokenStore } = setup({
|
||||
experimental: {
|
||||
flags: {
|
||||
queryMissingTokens: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const apiTokenStoreGet = jest.spyOn(apiTokenStore, 'get');
|
||||
|
||||
const invalidToken = 'invalid-token';
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(
|
||||
await apiTokenService.getUserForToken(invalidToken),
|
||||
).toBeUndefined();
|
||||
}
|
||||
expect(apiTokenStoreGet).toHaveBeenCalledTimes(1);
|
||||
|
||||
// after more than 5 minutes we should be able to query again
|
||||
jest.advanceTimersByTime(minutesToMilliseconds(6));
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(
|
||||
await apiTokenService.getUserForToken(invalidToken),
|
||||
).toBeUndefined();
|
||||
}
|
||||
expect(apiTokenStoreGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
@ -36,6 +36,7 @@ import {
|
||||
omitKeys,
|
||||
} from '../util';
|
||||
import type EventService from '../features/events/event-service';
|
||||
import { addMinutes, isPast } from 'date-fns';
|
||||
|
||||
const resolveTokenPermissions = (tokenType: string) => {
|
||||
if (tokenType === ApiTokenType.ADMIN) {
|
||||
@ -62,6 +63,8 @@ export class ApiTokenService {
|
||||
|
||||
private activeTokens: IApiToken[] = [];
|
||||
|
||||
private queryAfter = new Map<string, Date>();
|
||||
|
||||
private initialized = false;
|
||||
|
||||
private eventService: EventService;
|
||||
@ -185,10 +188,19 @@ export class ApiTokenService {
|
||||
);
|
||||
}
|
||||
|
||||
const nextAllowedQuery = this.queryAfter.get(secret) ?? 0;
|
||||
if (
|
||||
!token &&
|
||||
isPast(nextAllowedQuery) &&
|
||||
this.flagResolver.isEnabled('queryMissingTokens', flagContext)
|
||||
) {
|
||||
if (this.queryAfter.size > 1000) {
|
||||
// establish a max limit for queryAfter size to prevent memory leak
|
||||
this.queryAfter.clear();
|
||||
}
|
||||
// prevent querying the same invalid secret multiple times. Expire after 5 minutes
|
||||
this.queryAfter.set(secret, addMinutes(new Date(), 5));
|
||||
|
||||
token = await this.store.get(secret);
|
||||
if (token) {
|
||||
this.activeTokens.push(token);
|
||||
|
37
src/test/e2e/stores/api-token-store.e2e.test.ts
Normal file
37
src/test/e2e/stores/api-token-store.e2e.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import dbInit, { type ITestDb } from '../helpers/database-init';
|
||||
import getLogger from '../../fixtures/no-logger';
|
||||
import type { IUnleashStores } from '../../../lib/types';
|
||||
import { ApiTokenType } from '../../../lib/types/models/api-token';
|
||||
|
||||
let stores: IUnleashStores;
|
||||
let db: ITestDb;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit('api_token_store_serial', getLogger);
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('get token is undefined when not exist', async () => {
|
||||
const token = await stores.apiTokenStore.get('abcde123');
|
||||
expect(token).toBeUndefined();
|
||||
});
|
||||
|
||||
test('get token returns the token when exists', async () => {
|
||||
const newToken = await stores.apiTokenStore.insert({
|
||||
secret: 'abcde321',
|
||||
environment: 'default',
|
||||
type: ApiTokenType.ADMIN,
|
||||
projects: [],
|
||||
tokenName: 'admin-test-token',
|
||||
});
|
||||
const foundToken = await stores.apiTokenStore.get('abcde321');
|
||||
expect(foundToken).toBeDefined();
|
||||
expect(foundToken.secret).toBe(newToken.secret);
|
||||
expect(foundToken.environment).toBe(newToken.environment);
|
||||
expect(foundToken.tokenName).toBe(newToken.tokenName);
|
||||
expect(foundToken.type).toBe(newToken.type);
|
||||
});
|
8
src/test/fixtures/fake-api-token-store.ts
vendored
8
src/test/fixtures/fake-api-token-store.ts
vendored
@ -5,7 +5,6 @@ import type {
|
||||
IApiTokenCreate,
|
||||
} from '../../lib/types/models/api-token';
|
||||
|
||||
import NotFoundError from '../../lib/error/notfound-error';
|
||||
import EventEmitter from 'events';
|
||||
|
||||
export default class FakeApiTokenStore
|
||||
@ -39,11 +38,8 @@ export default class FakeApiTokenStore
|
||||
}
|
||||
|
||||
async get(key: string): Promise<IApiToken> {
|
||||
const token = this.tokens.find((t) => t.secret === key);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
throw new NotFoundError(`Could not find token with secret ${key}`);
|
||||
// get can return undefined. See api-token-store.e2e.test.ts
|
||||
return this.tokens.find((t) => t.secret === key);
|
||||
}
|
||||
|
||||
async getAll(): Promise<IApiToken[]> {
|
||||
|
Loading…
Reference in New Issue
Block a user