src/gridsome.server.ts
import {
InvalidArgumentError,
InvalidCharacterError,
MissingKeyError,
} from "./exception";
import { IApi, IOptions } from "./interface";
import { writeFileSync } from "fs";
import { v4, v6 } from "is-ip";
import isUrlHttp from "is-url-http";
import mimeDb from "mime-db";
import { isAbsolute } from "path";
class GridsomePluginHtaccess {
protected _options: IOptions;
protected _htaccessLines: Array<string>;
protected _htaccessContent: string;
public constructor(api: IApi, options: IOptions) {
this._htaccessLines = [];
this._options = options;
this._htaccessContent = "";
api.beforeBuild(() => {
console.time("gridsome-plugin-htaccess");
try {
this._checkOptions();
} catch (error) {
if (
error instanceof InvalidCharacterError ||
error instanceof TypeError ||
error instanceof MissingKeyError ||
error instanceof RangeError ||
error instanceof InvalidArgumentError
) {
console.error(`gridsome-plugin-htaccess: ${error.message}`);
console.timeEnd("gridsome-plugin-htaccess");
return;
}
throw error;
}
this._insertDisableDirectoryIndex();
this._insertDisableServerSignature();
this._insertPingable();
this._insertForceHttps();
this._insertTextCompression();
this._insertNotCachedFiles();
this._insertRedirections();
this._insertPreventScriptInjection();
this._insertPreventDdosAttacks();
this._insertCustomHeaders();
this._insertFileExpirations();
this._insertBlockedUserAgents();
this._insertBlockedIp();
this._insertFeaturePolicy();
this._insertContentSecurityPolicy();
this._setHtaccessContent();
this._insertCustomContent();
this._writeHtaccessFile();
console.timeEnd("gridsome-plugin-htaccess");
});
}
public static defaultOptions(): IOptions {
return {
blockedIp: [],
blockedUserAgents: [],
contentSecurityPolicy: {},
customHeaders: {},
disableDirectoryIndex: false,
disableServerSignature: false,
featurePolicy: {},
/**
* @todo
*/
fileExpirations: {},
forceHttps: false,
notCachedFiles: [],
pingable: true,
/**
* @todo
*/
preventImageHotLinking: true,
/**
* @todo
*/
preventScriptInjection: false,
redirections: [],
textCompression: [],
};
}
private _checkOptions(): void {
this._checkBlockedIpOption();
this._checkBlockedUserAgentsOption();
this._checkContentSecurityPolicyOption();
this._checkCustomContent();
this._checkCustomHeadersOption();
this._checkFileExpirations();
this._checkDisableDirectoryIndexOption();
this._checkFeaturePolicyOption();
this._checkForceHttpsOption();
this._checkNotCachedFilesOption();
this._checkPingableOption();
this._checkPreventDdosAttacksOption();
this._checkPreventScriptInjectionOption();
this._checkRedirectionsOption();
this._checkDisableServerSignatureOption();
this._checkTextCompressionOption();
}
private _insertDisableDirectoryIndex(): void {
if (this._options.disableDirectoryIndex) {
this._htaccessLines.push("# Disable directory index");
this._htaccessLines.push("Options All -Indexes");
this._htaccessLines.push("\n");
}
}
private _insertDisableServerSignature(): void {
if (this._options.disableServerSignature) {
this._htaccessLines.push(
"# Prevent your server from sending the version of the server"
);
this._htaccessLines.push("ServerSignature Off");
this._htaccessLines.push("\n");
}
}
private _insertPingable(): void {
if (!this._options.pingable) {
this._htaccessLines.push(
"# Prevent from being able to ping this domain"
);
this._htaccessLines.push("RewriteEngine on");
this._htaccessLines.push(
"RewriteCond %{REQUEST_METHOD} ^(TRACE|TRACK)"
);
this._htaccessLines.push("RewriteRule .* - [F]");
this._htaccessLines.push("\n");
}
}
private _insertForceHttps(): void {
if (this._options.forceHttps) {
this._htaccessLines.push(
"# Users' browser will be forced to visit the HTTPS version of your web app"
);
this._htaccessLines.push("RewriteEngine On");
this._htaccessLines.push("RewriteCond %{HTTPS} off");
this._htaccessLines.push(
"RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R,L]"
);
this._htaccessLines.push(
`Header set Strict-Transport-Security "max-age=31536000; includeSubDomains"`
);
this._htaccessLines.push("\n");
}
}
private _insertTextCompression(): void {
if (this._options.textCompression.length > 0) {
this._htaccessLines.push("# Enable text compression");
this._htaccessLines.push("<IfModule mod_deflate.c>");
for (const mimeType of this._options.textCompression) {
this._htaccessLines.push(
`\tAddOutputFilterByType DEFLATE ${mimeType}`
);
}
this._htaccessLines.push("</IfModule>");
this._htaccessLines.push("\n");
}
}
private _setHtaccessContent(): void {
if (this._htaccessLines.length > 0) {
this._htaccessContent = this._htaccessLines.join("\n");
}
}
private _writeHtaccessFile(): void {
if (this._htaccessContent.length > 0) {
writeFileSync("./static/.htaccess", this._htaccessContent);
}
}
private _insertNotCachedFiles(): void {
if (this._options.notCachedFiles.length > 0) {
this._htaccessLines.push(
"# Prevent the following files to be cached by your users' browser"
);
for (const file of this._options.notCachedFiles) {
this._htaccessLines.push(`<Files "${file}">`);
this._htaccessLines.push("\t<IfModule mod_expires.c>");
this._htaccessLines.push("\t\tExpiresActive Off");
this._htaccessLines.push("\t</IfModule>");
this._htaccessLines.push("\t<IfModule mod_headers.c>");
this._htaccessLines.push("\t\tFileETag None");
this._htaccessLines.push("\t\tHeader unset ETag");
this._htaccessLines.push("\t\tHeader unset Pragma");
this._htaccessLines.push("\t\tHeader unset Cache-Control");
this._htaccessLines.push("\t\tHeader unset Last-Modified");
this._htaccessLines.push(`\t\tHeader set Pragma "no-cache"`);
this._htaccessLines.push(
`\t\tHeader set Cache-Control "max-age=0, no-cache, no-store, must-revalidate"`
);
this._htaccessLines.push(
`\t\tHeader set Expires "Thu, 1 Jan 1970 00:00:00 GMT"`
);
this._htaccessLines.push("\t</IfModule>");
this._htaccessLines.push("</Files>");
}
this._htaccessLines.push("\n");
}
}
private _insertRedirections(): void {
if (this._options.redirections.length > 0) {
this._htaccessLines.push("# Permanents redirections (301)");
for (const { from, to } of this._options.redirections) {
this._htaccessLines.push(`Redirect 301 ${from} ${to}`);
}
this._htaccessLines.push("\n");
}
}
private _insertPreventScriptInjection(): void {
if (this._options.preventScriptInjection) {
this._htaccessLines.push("# Preventing script injection");
this._htaccessLines.push("<IfModule mod_rewrite.c>");
this._htaccessLines.push("\tRewriteEngine On");
this._htaccessLines.push(
`\tRewriteCond %{QUERY_STRING} (\<|%3C).*script.*(\>|%3E) [NC,OR]`
);
this._htaccessLines.push(
`\tRewriteCond %{QUERY_STRING} GLOBALS(=|\[|\%[0-9A-Z]{0,2}) [OR]`
);
this._htaccessLines.push(
`\tRewriteCond %{QUERY_STRING} _REQUEST(=|\[|\%[0-9A-Z]{0,2})`
);
this._htaccessLines.push("\tRewriteRule .* index.html [F,L]");
this._htaccessLines.push("</IfModule>");
this._htaccessLines.push("<IfModule mod_headers.c>");
this._htaccessLines.push(
`\tHeader set X-XSS-Protection "1; mode=block"`
);
this._htaccessLines.push("</IfModule>");
this._htaccessLines.push("\n");
}
}
private _insertPreventDdosAttacks(): void {
if (
"preventDdosAttacks" in this._options &&
this._options.preventDdosAttacks instanceof Object &&
"downloadedFilesSizeLimit" in this._options.preventDdosAttacks
) {
this._htaccessLines.push("# Preventing DDOS Attacks");
this._htaccessLines.push(
`LimitRequestBody ${this._options.preventDdosAttacks.downloadedFilesSizeLimit}`
);
this._htaccessLines.push("\n");
}
}
private _insertCustomHeaders(): void {
if (Object.keys(this._options.customHeaders).length > 0) {
this._htaccessLines.push("# Custom headers");
for (const headerName in this._options.customHeaders) {
const headerContent = this._options.customHeaders[headerName];
this._htaccessLines.push(
`Header set ${headerName} "${headerContent}"`
);
}
this._htaccessLines.push("\n");
}
}
private _insertBlockedUserAgents(): void {
if (this._options.blockedUserAgents.length > 0) {
const userAgents = this._options.blockedUserAgents.reduce(
(UAs, UA) => `${UAs}|${UA}`
);
this._htaccessLines.push("# Blocked user agents");
this._htaccessLines.push(
`SetEnvIfNoCase ^User-Agent$ .*(${userAgents}) HTTP_SAFE_BADBOT`
);
this._htaccessLines.push("Deny from env=HTTP_SAFE_BADBOT");
this._htaccessLines.push("\n");
}
}
private _insertBlockedIp(): void {
if (this._options.blockedIp.length > 0) {
this._htaccessLines.push("# Block IP addresses");
this._htaccessLines.push("order allow,deny");
for (const ip of this._options.blockedIp) {
this._htaccessLines.push(`deny from ${ip}`);
}
this._htaccessLines.push("allow from all");
this._htaccessLines.push("\n");
}
}
private _insertFeaturePolicy(): void {
if (Object.keys(this._options.featurePolicy).length > 0) {
const features: Array<string> = [];
for (const featureName in this._options.featurePolicy) {
let values = this._options.featurePolicy[featureName];
values = values.map(value =>
["none", "src", "self"].includes(value)
? `'${value}'`
: value
);
const featureValues = values.join(" ");
features.push(`${featureName} ${featureValues}`);
}
const featureDirectives = features.reduce(
(feats, feat) => `${feats}; ${feat}`
);
this._htaccessLines.push("# Feature policy");
this._htaccessLines.push(
`Header set Feature-Policy "${featureDirectives}"`
);
this._htaccessLines.push("\n");
}
}
private _insertContentSecurityPolicy(): void {
if (Object.keys(this._options.contentSecurityPolicy).length > 0) {
const policies: Array<string> = [];
for (const policyName in this._options.contentSecurityPolicy) {
let values = this._options.contentSecurityPolicy[policyName];
values = values.map(policy =>
[
"none",
"src",
"self",
"unsafe-eval",
"unsafe-hashes",
"unsafe-inline",
"strict-dynamic",
"report-sample",
].includes(policy)
? `'${policy}'`
: policy
);
const policyValues = values.join(" ");
policies.push(`${policyName} ${policyValues}`);
}
const policyDirectives = policies.reduce(
(polices, police) => `${polices}; ${police}`
);
this._htaccessLines.push("# Content Security Policy");
this._htaccessLines.push(
`Header set Content-Security-Policy "${policyDirectives}"`
);
this._htaccessLines.push("\n");
}
}
private _insertFileExpirations(): void {
if (
"default" in this._options.fileExpirations &&
this._options.fileExpirations.default !== undefined &&
this._options.fileExpirations.default.length > 0
) {
this._htaccessLines.push("# Default file expiration");
this._htaccessLines.push("<IfModule mod_expires.c>");
this._htaccessLines.push(
`\tExpiresDefault "${this._options.fileExpirations.default}"`
);
this._htaccessLines.push("</IfModule>");
this._htaccessLines.push("\n");
}
if (
"fileTypes" in this._options.fileExpirations &&
this._options.fileExpirations.fileTypes instanceof Object
) {
const numberOfFileTypes = Object.keys(
this._options.fileExpirations.fileTypes
).length;
if (numberOfFileTypes > 0) {
this._htaccessLines.push("# Files expirations");
this._htaccessLines.push("<IfModule mod_expires.c>");
}
for (const mimeType in this._options.fileExpirations.fileTypes) {
const expiration = this._options.fileExpirations.fileTypes[
mimeType
];
this._htaccessLines.push(
`\tExpiresByType ${mimeType} "${expiration}"`
);
}
if (numberOfFileTypes > 0) {
this._htaccessLines.push("</IfModule>");
this._htaccessLines.push("\n");
}
}
}
private _throwIfMissingOption(option: string): void {
if (!(option in this._options)) {
throw new MissingKeyError(`"${option}" must be present`);
}
}
private _throwIfOptionNotBoolean(option: string): void {
if (typeof this._options[option] !== "boolean") {
throw new TypeError(`"${option}" must be a boolean`);
}
}
private _throwIfOptionNotArray(option: string): void {
if (!Array.isArray(this._options[option])) {
throw new TypeError(`"${option}" must be an array`);
}
}
private _throwIfOptionNotArrayOfStrings(option: string): void {
for (const [index, item] of this._options[option].entries()) {
if (typeof item !== "string") {
throw new TypeError(`"${option}[${index}]" must be a string`);
}
}
}
private _throwIfOptionNotObject(option: string): void {
if (!(this._options[option] instanceof Object)) {
throw new TypeError(`"${option}" must be an object`);
}
}
private _throwIfOptionNotObjectOfArrays(option: string): void {
for (const key in this._options[option]) {
const value = this._options[option][key];
if (!Array.isArray(value)) {
throw new TypeError(`"${option}.${key}" must be an array`);
}
}
}
private _throwIfOptionNotObjectOfStrings(option: string) {
for (const key in this._options[option]) {
const value = this._options[option][key];
if (typeof value !== "string") {
throw new TypeError(`"${option}.${key}" must be a string`);
}
}
}
private _throwIfOptionNotObjectOfArraysOfStrings(option: string): void {
for (const key in this._options[option]) {
const array = this._options[option][key];
for (const [index, item] of array.entries()) {
if (typeof item !== "string") {
throw new TypeError(
`"${option}.${key}[${index}]" must be a string`
);
}
}
}
}
private _checkBlockedIpOption(): void {
const optionName = "blockedIp";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotArray(optionName);
this._throwIfOptionNotArrayOfStrings(optionName);
for (const [index, ip] of this._options.blockedIp.entries()) {
if (!v4(ip) && !v6(ip)) {
throw new InvalidArgumentError(
`"blockedIp[${index}]" must be a valid IP`
);
}
}
}
private _checkBlockedUserAgentsOption(): void {
const optionName = "blockedUserAgents";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotArray(optionName);
this._throwIfOptionNotArrayOfStrings(optionName);
}
private _checkContentSecurityPolicyOption(): void {
const optionName = "contentSecurityPolicy";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotObject(optionName);
this._throwIfOptionNotObjectOfArrays(optionName);
this._throwIfOptionNotObjectOfArraysOfStrings(optionName);
for (const key in this._options.contentSecurityPolicy) {
const values = this._options.contentSecurityPolicy[key];
for (const [index, value] of values.entries()) {
if (value.includes('"')) {
throw new InvalidCharacterError(
`"${optionName}.${key}[${index}]" contains a forbidden double quote`
);
}
}
}
}
private _checkCustomHeadersOption(): void {
const optionName = "customHeaders";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotObject(optionName);
this._throwIfOptionNotObjectOfStrings(optionName);
for (const headerName in this._options.customHeaders) {
const headerContent = this._options.customHeaders[headerName];
if (headerContent.includes('"')) {
throw new InvalidCharacterError(
`"${optionName}.${headerName}" contains a forbidden double quote`
);
}
}
}
private _checkFileExpirations(): void {
const optionName = "fileExpirations";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotObject(optionName);
if ("default" in this._options.fileExpirations) {
if (typeof this._options.fileExpirations.default !== "string") {
throw new TypeError(
`"fileExpirations.default" must be a string`
);
}
if (this._options.fileExpirations.default.includes('"')) {
throw new InvalidCharacterError(
`"fileExpirations.default" must not contain any double quote`
);
}
}
if ("fileTypes" in this._options.fileExpirations) {
if (!(this._options.fileExpirations.fileTypes instanceof Object)) {
throw new TypeError(
`"fileExpirations.fileTypes" must be an object`
);
}
for (const mimeType in this._options.fileExpirations.fileTypes) {
if (!(mimeType in mimeDb)) {
throw new InvalidArgumentError(
`"fileExpirations.fileTypes.${mimeType}" must be a valid MIME type`
);
}
const expiration = this._options.fileExpirations.fileTypes[
mimeType
];
if (typeof expiration !== "string") {
throw new TypeError(
`"fileExpirations.fileTypes.${mimeType}" must be a string`
);
}
if (expiration.includes('"')) {
throw new InvalidCharacterError(
`"fileExpirations.fileTypes.${mimeType}" must not contain any double quote`
);
}
}
}
}
private _checkDisableDirectoryIndexOption(): void {
const optionName = "disableDirectoryIndex";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotBoolean(optionName);
}
private _checkFeaturePolicyOption(): void {
const optionName = "featurePolicy";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotObject(optionName);
this._throwIfOptionNotObjectOfArrays(optionName);
this._throwIfOptionNotObjectOfArraysOfStrings(optionName);
for (const key in this._options.featurePolicy) {
const values = this._options.featurePolicy[key];
for (const [index, value] of values.entries()) {
if (value.includes('"')) {
throw new InvalidCharacterError(
`"${optionName}.${key}[${index}]" contains a forbidden double quote`
);
}
}
}
}
private _checkForceHttpsOption(): void {
const optionName = "forceHttps";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotBoolean(optionName);
}
private _checkNotCachedFilesOption(): void {
const optionName = "notCachedFiles";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotArray(optionName);
this._throwIfOptionNotArrayOfStrings(optionName);
for (const [index, file] of this._options.notCachedFiles.entries()) {
if (file.includes('"')) {
throw new InvalidCharacterError(
`"${optionName}[${index}]" contains a forbidden double quote`
);
}
}
}
private _checkPingableOption(): void {
const optionName = "pingable";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotBoolean(optionName);
}
private _checkPreventDdosAttacksOption(): void {
if (
"preventDdosAttacks" in this._options &&
this._options.preventDdosAttacks !== undefined
) {
const optionName = "preventDdosAttacks";
this._throwIfOptionNotObject(optionName);
if (
!(
"downloadedFilesSizeLimit" in
this._options.preventDdosAttacks
)
) {
throw new MissingKeyError(
`"${optionName}.downloadedFilesSizeLimit" must be present`
);
}
if (
typeof this._options.preventDdosAttacks
.downloadedFilesSizeLimit !== "number"
) {
throw new TypeError(
`"${optionName}.downloadedFilesSizeLimit" must be a number`
);
}
if (this._options.preventDdosAttacks.downloadedFilesSizeLimit < 0) {
throw new RangeError(
`"${optionName}.downloadedFilesSizeLimit" must be greater or equal to zero`
);
}
}
}
private _checkPreventScriptInjectionOption(): void {
const optionName = "preventScriptInjection";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotBoolean(optionName);
}
private _checkRedirectionsOption(): void {
const optionName = "redirections";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotArray(optionName);
for (const [
index,
redirection,
] of this._options.redirections.entries()) {
if (!(redirection instanceof Object)) {
throw new TypeError(
`"${optionName}[${index}]" must be an object`
);
}
if (!("from" in redirection)) {
throw new MissingKeyError(
`"${optionName}[${index}].from" must be present`
);
}
if (!("to" in redirection)) {
throw new MissingKeyError(
`"${optionName}[${index}].to" must be present`
);
}
if (typeof redirection.from !== "string") {
throw new TypeError(
`"${optionName}[${index}].from" must be a string`
);
}
if (typeof redirection.to !== "string") {
throw new TypeError(
`"${optionName}[${index}].to" must be a string`
);
}
if (!isAbsolute(redirection.from) && !isUrlHttp(redirection.from)) {
throw new InvalidArgumentError(
`"${optionName}[${index}].from" must be an absolute path or a valid HTTP URL`
);
}
if (!isAbsolute(redirection.to) && !isUrlHttp(redirection.to)) {
throw new InvalidArgumentError(
`"${optionName}[${index}].to" must be an absolute path or a valid HTTP URL`
);
}
}
}
private _checkDisableServerSignatureOption(): void {
const optionName = "disableServerSignature";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotBoolean(optionName);
}
private _checkTextCompressionOption(): void {
const optionName = "textCompression";
this._throwIfMissingOption(optionName);
this._throwIfOptionNotArray(optionName);
this._throwIfOptionNotArrayOfStrings(optionName);
for (const [
index,
mimeType,
] of this._options.textCompression.entries()) {
if (!(mimeType in mimeDb)) {
throw new InvalidArgumentError(
`"textCompression[${index}]" must be a valid MIME type`
);
}
}
}
private _checkCustomContent(): void {
const optionName = "customContent";
if (
optionName in this._options &&
this._options.customContent !== undefined
) {
this._throwIfOptionNotObject(optionName);
if (!("order" in this._options.customContent)) {
throw new MissingKeyError(
`"${optionName}.order" must be present`
);
}
if (!("content" in this._options.customContent)) {
throw new MissingKeyError(
`"${optionName}.content" must be present`
);
}
if (
!["before", "after"].includes(this._options.customContent.order)
) {
throw new RangeError(
`"${optionName}.order" must be one of [before, after]`
);
}
if (typeof this._options.customContent.content !== "string") {
throw new TypeError(`"${optionName}.content" must be a string`);
}
}
}
private _insertCustomContent(): void {
if (
"customContent" in this._options &&
this._options.customContent !== undefined
) {
if (this._options.customContent.order === "before") {
this._htaccessContent = `${this._options.customContent.content}\n\n${this._htaccessContent}`;
} else if (this._options.customContent.order === "after") {
this._htaccessContent = `${this._htaccessContent}${this._options.customContent.content}\n`;
}
}
}
}
export default GridsomePluginHtaccess;