Skip to content

Commit

Permalink
feat(middleware): add HSTS header middleware factory
Browse files Browse the repository at this point in the history
  • Loading branch information
TomokiMiyauci committed Mar 16, 2023
1 parent 979e510 commit 4ead771
Show file tree
Hide file tree
Showing 7 changed files with 262 additions and 0 deletions.
13 changes: 13 additions & 0 deletions _dev_deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export {
assert,
assertEquals,
assertThrows,
} from "https://deno.land/std@0.177.0/testing/asserts.ts";
export { describe, it } from "https://deno.land/std@0.177.0/testing/bdd.ts";
export {
assertSpyCall,
assertSpyCalls,
spy,
} from "https://deno.land/std@0.177.0/testing/mock.ts";
export { Status } from "https://deno.land/std@0.177.0/http/http_status.ts";
export { equalsResponse } from "https://deno.land/x/http_utils@1.0.0-beta.13/response.ts";
12 changes: 12 additions & 0 deletions deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

export { type Middleware } from "https://deno.land/x/http_middleware@1.0.0/mod.ts";
export {
isNonNegativeInteger,
isString,
} from "https://deno.land/x/isx@1.0.0-beta.24/mod.ts";

export const enum SecurityHeaders {
StrictTransportSecurity = "strict-transport-security",
}
63 changes: 63 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

import { type Middleware, SecurityHeaders } from "./deps.ts";
import { StrictTransportSecurity, stringify } from "./sts.ts";

/**
* @example
* ```http
* Strict-Transport-Security: max-age=15552000; includeSubDomains
* ```
*/
const DefaultSts: StrictTransportSecurity = {
maxAge: 60 * 60 * 24 * 180, // half-year,
includeSubDomains: true,
};

/** Create `Strict-Transport-Security` header field value middleware.
*
* @example
* ```ts
* import { hstsHeader } from "https://deno.land/x/hsts_middleware@$VERSION/mod.ts";
* import { assertEquals } from "https://deno.land/std/testing/asserts.ts";
*
* declare const request: Request;
* const middleware = hsts();
* const response = await middleware(
* request,
* (request: Request) => new Response(),
* );
*
* assertEquals(
* response.headers.get(
* "strict-transport-security",
* "max-age=15552000; includeSubDomains",
* ),
* );
* ```
*
* @throws {TypeError} If the {@link StrictTransportSecurity.maxAge} is not non-negative integer.
*/
export function hsts(
strictTransportSecurity?: StrictTransportSecurity,
): Middleware {
const sts = strictTransportSecurity ?? DefaultSts;
const stsValue = stringify(sts);

return async (request, next) => {
const response = await next(request);

return withSts(response, stsValue);
};
}

export function withSts(response: Response, fieldValue: string): Response {
response = response.clone();

if (!response.headers.has(SecurityHeaders.StrictTransportSecurity)) {
response.headers.set(SecurityHeaders.StrictTransportSecurity, fieldValue);
}

return response;
}
75 changes: 75 additions & 0 deletions middleware_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { hsts, withSts } from "./middleware.ts";
import {
assert,
assertThrows,
describe,
equalsResponse,
it,
} from "./_dev_deps.ts";

describe("hsts", () => {
it("should include sts header", async () => {
const middleware = hsts();

const response = await middleware(
new Request("test:"),
() => new Response(),
);

assert(
await equalsResponse(
response,
new Response(null, {
headers: {
"strict-transport-security": "max-age=15552000; includeSubDomains",
},
}),
true,
),
);
});

it("should change sts header", async () => {
const middleware = hsts({
maxAge: 100,
includeSubDomains: true,
preload: true,
});

const response = await middleware(
new Request("test:"),
() => new Response(),
);

assert(
await equalsResponse(
response,
new Response(null, {
headers: {
"strict-transport-security":
"max-age=100; includeSubDomains; preload",
},
}),
true,
),
);
});

it("should throw error if the sts is invalid", () => {
assertThrows(() => hsts({ maxAge: NaN }));
});
});

describe("withSts", () => {
it("should add sts header", async () => {
assert(
await equalsResponse(
withSts(new Response(), "max-age=100"),
new Response(null, {
headers: { "strict-transport-security": "max-age=100" },
}),
true,
),
);
});
});
6 changes: 6 additions & 0 deletions mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

export { hsts } from "./middleware.ts";
export { type StrictTransportSecurity } from "./sts.ts";
export { type Middleware } from "./deps.ts";
53 changes: 53 additions & 0 deletions sts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright 2023-latest the httpland authors. All rights reserved. MIT license.
// This module is browser compatible.

import { isNonNegativeInteger } from "./deps.ts";

/** HTTP `Strict-Transport-Security` header field. */
export interface StrictTransportSecurity {
/** The number of seconds, after the reception of the STS header field, during which the UA regards the host. */
readonly maxAge: number;

/** Whether the rule applies to all subdomains or not. */
readonly includeSubDomains?: boolean;

/** Whether the domain do preload or not.
* @see https://hstspreload.org/
*/
readonly preload?: boolean;
}
const enum Directive {
maxAge = "max-age",
includeSubDomains = "includeSubDomains",
preload = "preload",
}

/** Serialize {@link StrictTransportSecurity} into string.
* @throws {TypeError} If the {@link StrictTransportSecurity.maxAge} is not non-negative integer.
*/
export function stringify(sts: StrictTransportSecurity): string {
assertValidSts(sts);

const maxAge = `${Directive.maxAge}=${sts.maxAge}`;
const includeSubDomains = sts.includeSubDomains
? Directive.includeSubDomains
: undefined;
const preload = sts.preload ? Directive.preload : undefined;
const directives: string[] = [
maxAge,
includeSubDomains,
preload,
].filter(Boolean) as string[];

return directives.join("; ");
}

enum Msg {
InvalidMaxAge = "maxAge must be non-negative integer.",
}

function assertValidSts(sts: StrictTransportSecurity): asserts sts {
if (!isNonNegativeInteger(sts.maxAge)) {
throw TypeError(Msg.InvalidMaxAge);
}
}
40 changes: 40 additions & 0 deletions sts_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { assertEquals, assertThrows, describe, it } from "./_dev_deps.ts";
import { type StrictTransportSecurity, stringify } from "./sts.ts";

describe("stringify", () => {
it("should return string if the input is valid StrictTransportSecurity", () => {
const table: [StrictTransportSecurity, string][] = [
[{ maxAge: 0 }, "max-age=0"],
[{ maxAge: 100 }, "max-age=100"],
[
{ maxAge: 100, includeSubDomains: true },
"max-age=100; includeSubDomains",
],
[
{ maxAge: 100, includeSubDomains: true, preload: true },
"max-age=100; includeSubDomains; preload",
],
[
{ maxAge: 100, includeSubDomains: false, preload: false },
"max-age=100",
],
];

table.forEach(([sts, expected]) => {
assertEquals(stringify(sts), expected);
});
});

it("should throw error if the input is invalid StrictTransportSecurity", () => {
const table: StrictTransportSecurity[] = [
{ maxAge: NaN },
{ maxAge: 1.1 },
{ maxAge: -1 },
{ maxAge: Infinity },
];

table.forEach((sts) => {
assertThrows(() => stringify(sts));
});
});
});

0 comments on commit 4ead771

Please sign in to comment.