Skip to content

Commit

Permalink
fix: skip content negotiation for health check endpoint
Browse files Browse the repository at this point in the history
- 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.
  • Loading branch information
seansica committed Nov 17, 2024
1 parent 489e6ba commit e3530b9
Show file tree
Hide file tree
Showing 5 changed files with 96 additions and 93 deletions.
17 changes: 17 additions & 0 deletions bruno/Health Ping.bru
Original file line number Diff line number Diff line change
@@ -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);
});
}
2 changes: 1 addition & 1 deletion bruno/collection.bru
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
headers {
Accept: application/taxii+json;version=2.1
~Accept: application/taxii+json;version=2.1
}
26 changes: 19 additions & 7 deletions src/common/interceptors/set-response-media-type.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,43 @@ 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,
MediaTypeObject,
} 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<any> {
return next.handle().pipe(
map(data => {
const req = context.switchToHttp().getRequest<Request>();
const res = context.switchToHttp().getResponse<Response>();
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;
})
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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:
Expand All @@ -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 (!(<any>Object).values(SupportedMediaTypes).includes(mediaType.type)) {
return false;
}

if (
!(<any>Object).values(SupportedMediaSubTypes).includes(mediaType.subType)
) {
return false;
}

return true;
}
}
}
58 changes: 31 additions & 27 deletions src/common/middleware/content-negotiation/media-type-object.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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;
Expand All @@ -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;
}
}
}

0 comments on commit e3530b9

Please sign in to comment.