From ac1e459356d2cb9db3a4906e2fa821eed62369bb Mon Sep 17 00:00:00 2001 From: David Nagy Date: Mon, 21 Oct 2024 15:58:30 +0200 Subject: [PATCH] feat: add header validation utilities (#25) --- .../validation/validateMiddleware.test.ts | 104 ++++++++++++++++- src/server/validation/validateMiddleware.ts | 11 +- src/server/validation/validateRequest.test.ts | 107 +++++++++++++++++- src/server/validation/validateRequest.ts | 12 +- 4 files changed, 227 insertions(+), 7 deletions(-) diff --git a/src/server/validation/validateMiddleware.test.ts b/src/server/validation/validateMiddleware.test.ts index cae7a22..918b3f2 100644 --- a/src/server/validation/validateMiddleware.test.ts +++ b/src/server/validation/validateMiddleware.test.ts @@ -1,5 +1,8 @@ +import { jsonSchema, StringMap } from '@naturalcycles/js-lib' import { debugResource } from '../../test/debug.resource' -import { expressTestService } from '../../testing' +import { ExpressApp, expressTestService } from '../../testing' +import { getDefaultRouter } from '../getDefaultRouter' +import { validateHeaders } from './validateMiddleware' const app = expressTestService.createAppFromResource(debugResource) afterAll(async () => { @@ -39,3 +42,102 @@ Input: { pw: 'REDACTED' }", } `) }) + +describe('validateHeader', () => { + let app: ExpressApp + interface TestResponse { + ok: 1 + headers: StringMap + } + + beforeAll(async () => { + const resource = getDefaultRouter() + const schema = jsonSchema.object({ + shortstring: jsonSchema.string().min(8).max(16), + numeric: jsonSchema.string(), + bool: jsonSchema.string(), + sessionid: jsonSchema.string(), + }) + resource.get('/', validateHeaders(schema, { redactPaths: ['sessionid'] }), async (req, res) => { + res.json({ ok: 1, headers: req.headers }) + }) + app = expressTestService.createAppFromResource(resource) + }) + + afterAll(async () => { + await app.close() + }) + + test('should pass valid headers', async () => { + const response = await app.get('', { + headers: { + shortstring: 'shortstring', + numeric: '123', + bool: '1', + sessionid: 'sessionid', + }, + }) + + expect(response).toMatchObject({ ok: 1 }) + expect(response.headers).toEqual({ + shortstring: 'shortstring', + numeric: '123', + bool: '1', + sessionid: 'sessionid', + }) + }) + + test('should throw error on invalid headers', async () => { + const err = await app.expectError({ + url: '', + method: 'GET', + headers: { + shortstring: 'short', + numeric: '123', + bool: '1', + sessionid: 'sessionid', + }, + }) + + expect(err.data.responseStatusCode).toBe(400) + expect(err.cause.message).toContain( + 'request headers/shortstring must NOT have fewer than 8 characters', + ) + }) + + test('should list all errors (and not stop at the first error)', async () => { + const err = await app.expectError({ + url: '', + method: 'GET', + headers: { + shortstring: 'short', + // numeric: '123', + bool: '1', + sessionid: 'sessionid', + }, + }) + + expect(err.data.responseStatusCode).toBe(400) + expect(err.cause.message).toContain( + 'request headers/shortstring must NOT have fewer than 8 characters', + ) + expect(err.cause.message).toContain("request headers must have required property 'numeric'") + }) + + test('should redact sensitive data', async () => { + const err = await app.expectError({ + url: '', + method: 'GET', + headers: { + shortstring: 'short', + numeric: '127', + bool: '1', + sessionid: 'sessionid', + }, + }) + + expect(err.data.responseStatusCode).toBe(400) + expect(err.cause.message).toContain("REDACTED: 'REDACTED'") + expect(err.cause.message).not.toContain('sessionid') + }) +}) diff --git a/src/server/validation/validateMiddleware.ts b/src/server/validation/validateMiddleware.ts index 7249043..85a2a10 100644 --- a/src/server/validation/validateMiddleware.ts +++ b/src/server/validation/validateMiddleware.ts @@ -26,6 +26,13 @@ export function validateQuery( return validateObject('query', schema, opt) } +export function validateHeaders( + schema: JsonSchema | JsonSchemaBuilder | AjvSchema, + opt: ReqValidationOptions = {}, +): BackendRequestHandler { + return validateObject('headers', schema, opt) +} + /** * Validates req property (body, params or query). * Supports Joi schema or AjvSchema (from nodejs-lib). @@ -33,7 +40,7 @@ export function validateQuery( * Throws http 400 on error. */ function validateObject( - prop: 'body' | 'params' | 'query', + prop: 'body' | 'params' | 'query' | 'headers', schema: JsonSchema | JsonSchemaBuilder | AjvSchema, opt: ReqValidationOptions = {}, ): BackendRequestHandler { @@ -75,6 +82,6 @@ function redact(redactPaths: string[], obj: any, error: Error): void { .map(path => _get(obj, path) as string) .filter(Boolean) .forEach(secret => { - error.message = error.message.replace(secret, REDACTED) + error.message = error.message.replaceAll(secret, REDACTED) }) } diff --git a/src/server/validation/validateRequest.test.ts b/src/server/validation/validateRequest.test.ts index 4384a8f..948dfa8 100644 --- a/src/server/validation/validateRequest.test.ts +++ b/src/server/validation/validateRequest.test.ts @@ -1,6 +1,9 @@ -import { _inspect } from '@naturalcycles/nodejs-lib' +import { StringMap } from '@naturalcycles/js-lib' +import { _inspect, numberSchema, objectSchema, stringSchema } from '@naturalcycles/nodejs-lib' import { debugResource } from '../../test/debug.resource' -import { expressTestService } from '../../testing' +import { ExpressApp, expressTestService } from '../../testing' +import { getDefaultRouter } from '../getDefaultRouter' +import { validateRequest } from './validateRequest' const app = expressTestService.createAppFromResource(debugResource) afterAll(async () => { @@ -52,3 +55,103 @@ test('validateRequest', async () => { [1] "pw" length must be at least 8 characters long" `) }) + +describe('validateRequest.headers', () => { + let app: ExpressApp + interface TestResponse { + ok: 1 + headers: StringMap + } + + beforeAll(async () => { + const resource = getDefaultRouter() + resource.get('/', async (req, res) => { + validateRequest.headers( + req, + objectSchema({ + shortstring: stringSchema.min(8).max(16), + numeric: numberSchema, + bool: stringSchema, + sessionid: stringSchema, + }), + { redactPaths: ['sessionid'] }, + ) + + res.json({ ok: 1, headers: req.headers }) + }) + app = expressTestService.createAppFromResource(resource) + }) + + afterAll(async () => { + await app.close() + }) + + test('should pass valid headers', async () => { + const response = await app.get('', { + headers: { + shortstring: 'shortstring', + numeric: '123', + bool: '1', + sessionid: 'sessionid', + }, + }) + + expect(response).toMatchObject({ ok: 1 }) + expect(response.headers).toEqual({ + shortstring: 'shortstring', + numeric: 123, + bool: '1', + sessionid: 'sessionid', + }) + }) + + test('should throw error on invalid headers', async () => { + const err = await app.expectError({ + url: '', + method: 'GET', + headers: { + shortstring: 'short', + numeric: '123', + bool: '1', + sessionid: 'sessionid', + }, + }) + + expect(err.data.responseStatusCode).toBe(400) + expect(err.cause.message).toContain('"shortstring" length must be at least 8 characters long') + }) + + test('should list all errors (and not stop at the first error)', async () => { + const err = await app.expectError({ + url: '', + method: 'GET', + headers: { + shortstring: 'short', + numeric: 'text', + bool: '1', + sessionid: 'sessionid', + }, + }) + + expect(err.data.responseStatusCode).toBe(400) + expect(err.cause.message).toContain('"shortstring" length must be at least 8 characters long') + expect(err.cause.message).toContain('"numeric" must be a number') + }) + + test('should redact sensitive data', async () => { + const err = await app.expectError({ + url: '', + method: 'GET', + headers: { + shortstring: 'short', + numeric: '127', + bool: '1', + sessionid: 'sessionid', + }, + }) + + expect(err.data.responseStatusCode).toBe(400) + expect(err.cause.message).toContain('"REDACTED": "REDACTED"') + expect(err.cause.message).not.toContain('sessionid') + }) +}) diff --git a/src/server/validation/validateRequest.ts b/src/server/validation/validateRequest.ts index 02ea7ef..4f86fed 100644 --- a/src/server/validation/validateRequest.ts +++ b/src/server/validation/validateRequest.ts @@ -26,7 +26,7 @@ function redact(redactPaths: string[], obj: any, error: Error): void { .map(path => _get(obj, path) as string) .filter(Boolean) .forEach(secret => { - error.message = error.message.replace(secret, REDACTED) + error.message = error.message.replaceAll(secret, REDACTED) }) } @@ -55,9 +55,17 @@ class ValidateRequest { return this.validate(req, 'params', schema, opt) } + headers( + req: BackendRequest, + schema: AnySchema, + opt: ReqValidationOptions = {}, + ): T { + return this.validate(req, 'headers', schema, opt) + } + private validate( req: BackendRequest, - reqProperty: 'body' | 'params' | 'query', + reqProperty: 'body' | 'params' | 'query' | 'headers', schema: AnySchema, opt: ReqValidationOptions = {}, ): T {