Skip to content

Commit

Permalink
Content-Security-Policy: allow directive values to include functions
Browse files Browse the repository at this point in the history
This was removed in Helmet 4, which I regret. You can read more about it
on [this GitHub issue][0].

Closes [#243][0].

[0]: #243
  • Loading branch information
EvanHahn committed Aug 10, 2020
1 parent 1285a8d commit 6715dbc
Show file tree
Hide file tree
Showing 7 changed files with 283 additions and 98 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 4.1.0 - Unreleased

### Added

- `helmet.contentSecurityPolicy`:
- Directive values can now include functions, as they could in Helmet 3. See [#243](https://github.com/helmetjs/helmet/issues/243)

## 4.0.0 - 2020-08-02

See the [Helmet 4 upgrade guide](https://github.com/helmetjs/helmet/wiki/Helmet-4-upgrade-guide) for help upgrading from Helmet 3.
Expand Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ Each middleware's name is listed below.

This middleware performs very little validation. You should rely on CSP checkers like [CSP Evaluator](https://csp-evaluator.withgoogle.com/) instead.

`options.directives` is an object. Each key is a directive name in camel case (such as `defaultSrc`) or kebab case (such as `default-src`). Each value is an iterable (usually an array) of strings for that directive.
`options.directives` is an object. Each key is a directive name in camel case (such as `defaultSrc`) or kebab case (such as `default-src`). Each value is an iterable (usually an array) of strings or functions for that directive. If a function appears in the iterable, it will be called with the request and response.

`options.reportOnly` is a boolean, defaulting to `false`. If `true`, [the `Content-Security-Policy-Report-Only` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only) will be set instead.

Expand Down Expand Up @@ -150,9 +150,21 @@ app.use(
reportOnly: true,
})
);
```

See [this wiki page](https://github.com/helmetjs/helmet/wiki/Conditionally-using-middleware#i-want-to-use-some-middleware-with-different-options) to see how to set directives conditionally (to set per-request nonces, for example).
// Sets "Content-Security-Policy: default-src 'self';script-src 'self' 'nonce-e33ccde670f149c1789b1e1e113b0916'"
app.use((req, res, next) => {
res.locals.cspNonce = crypto.randomBytes(16).toString("hex");
next();
});
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", (req, res) => `'nonce-${res.locals.cspNonce}'`],
},
})
);
```

You can install this module separately as `helmet-csp`.

Expand Down
32 changes: 22 additions & 10 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ export interface HelmetOptions {
type MiddlewareOption<T> = false | T;

interface MiddlewareFunction {
(req: IncomingMessage, res: ServerResponse, next: () => void): void;
(
req: IncomingMessage,
res: ServerResponse,
next: (error?: Error) => void
): void;
}

function noop() {}

function helmet(options: Readonly<HelmetOptions> = {}) {
if (options.constructor.name === "IncomingMessage") {
throw new Error(
Expand Down Expand Up @@ -149,14 +151,24 @@ function helmet(options: Readonly<HelmetOptions> = {}) {
return function helmetMiddleware(
req: IncomingMessage,
res: ServerResponse,
next: () => void
next: (err?: unknown) => void
): void {
// All of Helmet's middleware is synchronous today, so we can get away with this.
// If that changes, we'll need to do something smarter here.
for (const middlewareFunction of middlewareFunctions) {
middlewareFunction(req, res, noop);
}
next();
const iterator = middlewareFunctions[Symbol.iterator]();

(function internalNext(err?: unknown) {
if (err) {
next(err);
return;
}

const iteration = iterator.next();
if (iteration.done) {
next();
} else {
const middlewareFunction = iteration.value;
middlewareFunction(req, res, internalNext);
}
})();
};
}

Expand Down
6 changes: 6 additions & 0 deletions middlewares/content-security-policy/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 3.1.0 - Unreleased

### Added

- Directive values can now include functions, as they could in Helmet 3. See [#243](https://github.com/helmetjs/helmet/issues/243)

## 3.0.0 - 2020-08-02

### Added
Expand Down
229 changes: 144 additions & 85 deletions middlewares/content-security-policy/index.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,107 @@
import { IncomingMessage, ServerResponse } from "http";

interface ContentSecurityPolicyDirectiveValueFunction {
(req: IncomingMessage, res: ServerResponse): string;
}

type ContentSecurityPolicyDirectiveValue =
| string
| ContentSecurityPolicyDirectiveValueFunction;

interface ContentSecurityPolicyDirectives {
[directiveName: string]: Iterable<ContentSecurityPolicyDirectiveValue>;
}

export interface ContentSecurityPolicyOptions {
directives?: {
[directiveName: string]: Iterable<string>;
};
directives?: ContentSecurityPolicyDirectives;
reportOnly?: boolean;
}

const DEFAULT_DIRECTIVES: ContentSecurityPolicyDirectives = {
"default-src": ["'self'"],
"base-uri": ["'self'"],
"block-all-mixed-content": [],
"font-src": ["'self'", "https:", "data:"],
"frame-ancestors": ["'self'"],
"img-src": ["'self'", "data:"],
"object-src": ["'none'"],
"script-src": ["'self'"],
"script-src-attr": ["'none'"],
"style-src": ["'self'", "https:", "'unsafe-inline'"],
"upgrade-insecure-requests": [],
};

const isRawPolicyDirectiveNameInvalid = (rawDirectiveName: string): boolean =>
rawDirectiveName.length === 0 || /[^a-zA-Z0-9-]/.test(rawDirectiveName);

const dashify = (str: string): string =>
str.replace(/[A-Z]/g, (capitalLetter) => "-" + capitalLetter.toLowerCase());

function getHeaderNameFromOptions({
const isDirectiveValueInvalid = (directiveValue: string): boolean =>
/;|,/.test(directiveValue);

const has = (obj: Readonly<object>, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);

function getHeaderName({
reportOnly,
}: ContentSecurityPolicyOptions): string {
}: Readonly<ContentSecurityPolicyOptions>): string {
if (reportOnly) {
return "Content-Security-Policy-Report-Only";
} else {
return "Content-Security-Policy";
}
}

function getHeaderValueFromOptions(
options: ContentSecurityPolicyOptions
): string {
if ("loose" in options) {
console.warn(
"Content-Security-Policy middleware no longer needs the `loose` parameter. You should remove it."
);
}
if ("setAllHeaders" in options) {
console.warn(
"Content-Security-Policy middleware no longer supports the `setAllHeaders` parameter. See <https://github.com/helmetjs/helmet/wiki/Setting-legacy-Content-Security-Policy-headers-in-Helmet-4>."
);
}
["disableAndroid", "browserSniff"].forEach((deprecatedOption) => {
if (deprecatedOption in options) {
console.warn(
`Content-Security-Policy middleware no longer does browser sniffing, so you can remove the \`${deprecatedOption}\` option. See <https://github.com/helmetjs/csp/issues/97> for discussion.`
);
}
});
function normalizeDirectives(
options: Readonly<ContentSecurityPolicyOptions>
): ContentSecurityPolicyDirectives {
const result: ContentSecurityPolicyDirectives = {};

const {
directives = {
"default-src": ["'self'"],
"base-uri": ["'self'"],
"block-all-mixed-content": [],
"font-src": ["'self'", "https:", "data:"],
"frame-ancestors": ["'self'"],
"img-src": ["'self'", "data:"],
"object-src": ["'none'"],
"script-src": ["'self'"],
"script-src-attr": ["'none'"],
"style-src": ["'self'", "https:", "'unsafe-inline'"],
"upgrade-insecure-requests": [],
},
} = options;

const directiveNamesUsed = new Set<string>();

const result = Object.entries(directives)
.map(([rawDirectiveName, rawDirectiveValue]) => {
if (isRawPolicyDirectiveNameInvalid(rawDirectiveName)) {
throw new Error(
`Content-Security-Policy received an invalid directive name ${JSON.stringify(
rawDirectiveName
)}`
);
}
const directiveName = dashify(rawDirectiveName);
if (directiveNamesUsed.has(directiveName)) {
throw new Error(
`Content-Security-Policy received a duplicate directive ${JSON.stringify(
directiveName
)}`
);
}
directiveNamesUsed.add(directiveName);
const { directives: rawDirectives = DEFAULT_DIRECTIVES } = options;

let directiveValue: string;
if (typeof rawDirectiveValue === "string") {
directiveValue = " " + rawDirectiveValue;
} else {
directiveValue = "";
for (const element of rawDirectiveValue) {
directiveValue += " " + element;
}
}
for (const rawDirectiveName in rawDirectives) {
if (!has(rawDirectives, rawDirectiveName)) {
continue;
}

if (!directiveValue) {
return directiveName;
}
if (isRawPolicyDirectiveNameInvalid(rawDirectiveName)) {
throw new Error(
`Content-Security-Policy received an invalid directive name ${JSON.stringify(
rawDirectiveName
)}`
);
}
const directiveName = dashify(rawDirectiveName);
if (has(result, directiveName)) {
throw new Error(
`Content-Security-Policy received a duplicate directive ${JSON.stringify(
directiveName
)}`
);
}

if (/;|,/.test(directiveValue)) {
const rawDirectiveValue = rawDirectives[rawDirectiveName];
let directiveValue: Iterable<ContentSecurityPolicyDirectiveValue>;
if (typeof rawDirectiveValue === "string") {
directiveValue = [rawDirectiveValue];
} else {
directiveValue = rawDirectiveValue;
}
for (const element of directiveValue) {
if (typeof element === "string" && isDirectiveValueInvalid(element)) {
throw new Error(
`Content-Security-Policy received an invalid directive value for ${JSON.stringify(
directiveName
)}`
);
}
}

return `${directiveName}${directiveValue}`;
})
.join(";");
result[directiveName] = directiveValue;
}

if (!directiveNamesUsed.has("default-src")) {
if (!("default-src" in result)) {
throw new Error(
"Content-Security-Policy needs a default-src but none was provided"
);
Expand All @@ -116,19 +110,84 @@ function getHeaderValueFromOptions(
return result;
}

function getHeaderValue(
req: IncomingMessage,
res: ServerResponse,
directives: ContentSecurityPolicyDirectives
): string | Error {
const result: string[] = [];

for (const directiveName in directives) {
if (!has(directives, directiveName)) {
continue;
}

const rawDirectiveValue = directives[directiveName];
let directiveValue = "";
for (const element of rawDirectiveValue) {
if (element instanceof Function) {
directiveValue += " " + element(req, res);
} else {
directiveValue += " " + element;
}
}

if (!directiveValue) {
result.push(directiveName);
} else if (isDirectiveValueInvalid(directiveValue)) {
return new Error(
`Content-Security-Policy received an invalid directive value for ${JSON.stringify(
directiveName
)}`
);
} else {
result.push(`${directiveName}${directiveValue}`);
}
}

return result.join(";");
}

function contentSecurityPolicy(
options: Readonly<ContentSecurityPolicyOptions> = {}
) {
const headerName = getHeaderNameFromOptions(options);
const headerValue = getHeaderValueFromOptions(options);
): (
req: IncomingMessage,
res: ServerResponse,
next: (err?: Error) => void
) => void {
if ("loose" in options) {
console.warn(
"Content-Security-Policy middleware no longer needs the `loose` parameter. You should remove it."
);
}
if ("setAllHeaders" in options) {
console.warn(
"Content-Security-Policy middleware no longer supports the `setAllHeaders` parameter. See <https://github.com/helmetjs/helmet/wiki/Setting-legacy-Content-Security-Policy-headers-in-Helmet-4>."
);
}
["disableAndroid", "browserSniff"].forEach((deprecatedOption) => {
if (deprecatedOption in options) {
console.warn(
`Content-Security-Policy middleware no longer does browser sniffing, so you can remove the \`${deprecatedOption}\` option. See <https://github.com/helmetjs/csp/issues/97> for discussion.`
);
}
});

const headerName = getHeaderName(options);
const directives = normalizeDirectives(options);

return function contentSecurityPolicyMiddleware(
_req: IncomingMessage,
req: IncomingMessage,
res: ServerResponse,
next: () => void
next: (error?: Error) => void
) {
res.setHeader(headerName, headerValue);
next();
const result = getHeaderValue(req, res, directives);
if (result instanceof Error) {
next(result);
} else {
res.setHeader(headerName, result);
next();
}
};
}

Expand Down
Loading

0 comments on commit 6715dbc

Please sign in to comment.