From e3530b9e66fddf21c02d897c0fa01dbad6bc9c07 Mon Sep 17 00:00:00 2001 From: Sean Sica <23294618+seansica@users.noreply.github.com> Date: Sun, 17 Nov 2024 15:15:57 -0500 Subject: [PATCH] fix: skip content negotiation for health check endpoint - Update content negotiation middleware to properly detect health check path - Add proper type safety to MediaTypeObject with enum validation - Handle health check responses in SetResponseMediaType interceptor - Add fallback content-type header for health endpoints - Fix TypeScript type mismatch errors in middleware The health check endpoint now bypasses TAXII content negotiation requirements while maintaining proper type safety throughout the rest of the API. --- bruno/Health Ping.bru | 17 ++++ bruno/collection.bru | 2 +- .../set-response-media-type.interceptor.ts | 26 ++++-- .../content-negotiation.middleware.ts | 86 ++++++------------- .../content-negotiation/media-type-object.ts | 58 +++++++------ 5 files changed, 96 insertions(+), 93 deletions(-) create mode 100644 bruno/Health Ping.bru diff --git a/bruno/Health Ping.bru b/bruno/Health Ping.bru new file mode 100644 index 0000000..a107947 --- /dev/null +++ b/bruno/Health Ping.bru @@ -0,0 +1,17 @@ +meta { + name: Health Ping + type: http + seq: 9 +} + +get { + url: {{host}}/health/ping + body: none + auth: none +} + +tests { + test("Verify response status code", function() { + expect(res.getStatus()).to.equal(204); + }); +} diff --git a/bruno/collection.bru b/bruno/collection.bru index 0ae725a..08dc28c 100644 --- a/bruno/collection.bru +++ b/bruno/collection.bru @@ -1,3 +1,3 @@ headers { - Accept: application/taxii+json;version=2.1 + ~Accept: application/taxii+json;version=2.1 } diff --git a/src/common/interceptors/set-response-media-type.interceptor.ts b/src/common/interceptors/set-response-media-type.interceptor.ts index 8088ed9..cef5329 100644 --- a/src/common/interceptors/set-response-media-type.interceptor.ts +++ b/src/common/interceptors/set-response-media-type.interceptor.ts @@ -4,7 +4,7 @@ import { Injectable, NestInterceptor, } from "@nestjs/common"; -import { map, Observable, tap } from "rxjs"; +import { map, Observable } from "rxjs"; import { Request, Response } from "express"; import { MEDIA_TYPE_TOKEN, @@ -12,23 +12,35 @@ import { } from "../middleware/content-negotiation"; /** - * Automatically sets response constants as defined in the `HeadersModel` enum. + * Automatically sets response Content-Type header based on the accepted media type. + * For health check endpoints, uses application/json. */ @Injectable() export class SetResponseMediaType implements NestInterceptor { + private readonly HEALTH_CHECK_PATH = "/health/ping"; + private readonly DEFAULT_CONTENT_TYPE = "application/json"; + intercept(context: ExecutionContext, next: CallHandler): Observable { return next.handle().pipe( map(data => { const req = context.switchToHttp().getRequest(); const res = context.switchToHttp().getResponse(); - const requestedMediaType: MediaTypeObject = req[MEDIA_TYPE_TOKEN]; - const contentType = requestedMediaType.toString(); - res.setHeader("Content-Type", contentType); + // Set content type based on path + if (req.originalUrl.endsWith(this.HEALTH_CHECK_PATH)) { + res.setHeader("Content-Type", this.DEFAULT_CONTENT_TYPE); + } else { + const requestedMediaType: MediaTypeObject = req[MEDIA_TYPE_TOKEN]; + if (requestedMediaType) { + res.setHeader("Content-Type", requestedMediaType.toString()); + } else { + // Fallback in case MEDIA_TYPE_TOKEN is not set + res.setHeader("Content-Type", this.DEFAULT_CONTENT_TYPE); + } + } - // Important: Return the data! return data; }) ); } -} +} \ No newline at end of file diff --git a/src/common/middleware/content-negotiation/content-negotiation.middleware.ts b/src/common/middleware/content-negotiation/content-negotiation.middleware.ts index 1de52c9..d6d4fe5 100644 --- a/src/common/middleware/content-negotiation/content-negotiation.middleware.ts +++ b/src/common/middleware/content-negotiation/content-negotiation.middleware.ts @@ -6,22 +6,20 @@ import { } from "../../exceptions"; import { RequestContext, RequestContextModel } from "../request-context"; import { MediaTypeObject } from "./media-type-object"; -import { - SupportedMediaTypes, - SupportedMediaSubTypes, -} from "./supported-media-types"; import { MEDIA_TYPE_TOKEN } from "./constants"; @Injectable() export class ContentNegotiationMiddleware implements NestMiddleware { private logger = new Logger(ContentNegotiationMiddleware.name); + private readonly HEALTH_CHECK_PATH = "/health/ping"; use(req: Request, res: Response, next: NextFunction) { - // Get a hook into the request context so we can do logging const ctx: RequestContext = RequestContextModel.get(); - // Do not enforce TAXII headers on health check endpoint - if (req.path == "/health/ping") { + if (req.originalUrl.endsWith(this.HEALTH_CHECK_PATH)) { + this.logger.debug( + `[${ctx["x-request-id"]}] Skipping content negotiation check on health check endpoint` + ); return next(); } @@ -39,21 +37,25 @@ export class ContentNegotiationMiddleware implements NestMiddleware { }); } - // If it exists, initialize a MediaTypeObject and store it on the request for later. Then check that the - // 'Accept' header value is valid. + // If it exists, validate the media type if (mediaType) { - // Instantiate the MediaTypeObject and write it to the Request object. Note that we use the MediaTypeObject to - // facilitate string parsing on the 'Accept' value and to gain access to some helper methods. - req[MEDIA_TYPE_TOKEN] = new MediaTypeObject(mediaType); - - // Validate the 'Accept' header - this.validate(req); - - // If we make it this far, then 'Accept' header is valid and we can proceed with processing request - return next(); + try { + // Instantiate the MediaTypeObject and write it to the Request object + req[MEDIA_TYPE_TOKEN] = new MediaTypeObject(mediaType); + + // Validate the 'Accept' header + this.validate(req); + + // If validation passes, proceed with the request + return next(); + } catch (error) { + this.logger.error( + `[${ctx["x-request-id"]}] Error processing media type: ${error.message}` + ); + throw error; + } } - // If the 'Accept' header is not found on the request, the TAXII server will throw a 'Bad Request' response throw new TaxiiBadRequestException({ title: "Invalid media type", description: @@ -64,57 +66,25 @@ export class ContentNegotiationMiddleware implements NestMiddleware { }); } - /** - * TODO write JSDoc description - * @param req - * @private - */ private validate(req: Request) { - // Get a hook into the request context so we can do logging const ctx: RequestContext = RequestContextModel.get(); - - //const acceptHeader: string = req.headers["accept"]; const mediaType: MediaTypeObject = req[MEDIA_TYPE_TOKEN]; - const isValid: boolean = this.isValidMediaType(mediaType); - - if (!isValid) { + // The validation is now handled in the MediaTypeObject constructor + // We just need to check if we got a valid MediaTypeObject + if (!mediaType || !(mediaType instanceof MediaTypeObject)) { this.logger.error( - `[${ - ctx["x-request-id"] - }] The Request's Accept header (${mediaType.toString()}) is either missing the appropriate TAXII media type or the media type is invalid.` + `[${ctx["x-request-id"]}] Invalid media type object` ); throw new TaxiiNotAcceptableException({ title: "Invalid Accept header", - description: `The media type specified in the Accept header (${mediaType.toString()}) is invalid`, + description: `The media type specified in the Accept header is invalid`, }); } this.logger.debug( - `[${ - ctx["x-request-id"] - }] The media type specified in the Accept header (${mediaType.toString()}) is valid` + `[${ctx["x-request-id"]}] The media type specified in the Accept header (${mediaType.toString()}) is valid` ); } - - /** - * Determines whether a given MediaTypeObject appropriately conforms to the required Accept header media type as - * defined by the TAXII 2.1 specification (section 1.6.8 Content Negotiation) - * @param mediaType This is the MediaTypeObject whose type, subType, and option attributes will be validated - * @private - */ - private isValidMediaType(mediaType: MediaTypeObject): boolean { - if (!(Object).values(SupportedMediaTypes).includes(mediaType.type)) { - return false; - } - - if ( - !(Object).values(SupportedMediaSubTypes).includes(mediaType.subType) - ) { - return false; - } - - return true; - } -} +} \ No newline at end of file diff --git a/src/common/middleware/content-negotiation/media-type-object.ts b/src/common/middleware/content-negotiation/media-type-object.ts index 7e68145..722d92b 100644 --- a/src/common/middleware/content-negotiation/media-type-object.ts +++ b/src/common/middleware/content-negotiation/media-type-object.ts @@ -1,34 +1,33 @@ -import { SupportedMediaVersion } from "./supported-media-types"; -import { TaxiiBadRequestException } from "../../exceptions"; +import { TaxiiBadRequestException } from "src/common/exceptions"; +import { SupportedMediaTypes, SupportedMediaSubTypes, SupportedMediaVersion } from "./supported-media-types"; export interface ParsedMediaTypeFields { - type; - subType; - version; + type: SupportedMediaTypes; + subType: SupportedMediaSubTypes; + version: string; } export class MediaTypeObject { - _type: string; - _subType: string; - _version: string; + private _type: SupportedMediaTypes; + private _subType: SupportedMediaSubTypes; + private _version: string; /** * Converts a string-formatted media type to an instance of MediaTypeObject * @param mediaType A string-formatted RFC-6838 Media Type */ constructor(acceptHeader: string) { - const parsed: ParsedMediaTypeFields = - MediaTypeObject.parseAcceptHeader(acceptHeader); + const parsed: ParsedMediaTypeFields = MediaTypeObject.parseAcceptHeader(acceptHeader); this._type = parsed.type; this._subType = parsed.subType; this._version = parsed.version; } - get type(): string { + get type(): SupportedMediaTypes { return this._type; } - get subType(): string { + get subType(): SupportedMediaSubTypes { return this._subType; } @@ -51,29 +50,36 @@ export class MediaTypeObject { }; } - private static parseAcceptHeader( - acceptHeader: string - ): ParsedMediaTypeFields { - // parsed object will be returned + private static parseAcceptHeader(acceptHeader: string): ParsedMediaTypeFields { const parsed = {} as ParsedMediaTypeFields; - // EXAMPLE: - // String "application/providers+json;version=2.1" is converted to - // array ["application", "providers+json;version=2.1"] const typeAndSubType: string[] = acceptHeader.split("/"); - parsed.type = typeAndSubType[0]; + + // Validate and convert type to enum + if (!Object.values(SupportedMediaTypes).includes(typeAndSubType[0] as SupportedMediaTypes)) { + throw new TaxiiBadRequestException({ + title: "Unsupported Media Type", + description: `${typeAndSubType[0]} is not a supported media type`, + }); + } + parsed.type = typeAndSubType[0] as SupportedMediaTypes; if (typeAndSubType[1]) { - // EXAMPLE: String "providers+json;version=2.1" is converted to array ["providers+json", "version=2.1"] const subTypeAndVersion: string[] = typeAndSubType[1].split(";"); - parsed.subType = subTypeAndVersion[0]; + + // Validate and convert subType to enum + if (!Object.values(SupportedMediaSubTypes).includes(subTypeAndVersion[0] as SupportedMediaSubTypes)) { + throw new TaxiiBadRequestException({ + title: "Unsupported Media SubType", + description: `${subTypeAndVersion[0]} is not a supported media subtype`, + }); + } + parsed.subType = subTypeAndVersion[0] as SupportedMediaSubTypes; if (subTypeAndVersion[1]) { - // "version=2.1" => ["version", "2.1"] const parsedVersion: string[] = subTypeAndVersion[1].split("="); if (parsedVersion[0] === "version") { - // set version switch (parsedVersion[1]) { case "2.1": parsed.version = SupportedMediaVersion.V21; @@ -86,11 +92,9 @@ export class MediaTypeObject { } } } else { - // This else block will execute if user did not specify a version, i.e., "application/taxii+json" - // In this case, we set the requested media type to the latest version parsed.version = SupportedMediaVersion.LATEST; } } return parsed; } -} +} \ No newline at end of file