diff --git a/src/core/utils/glob.spec.ts b/src/core/utils/glob.spec.ts index 6951827b..5b79955c 100644 --- a/src/core/utils/glob.spec.ts +++ b/src/core/utils/glob.spec.ts @@ -1,4 +1,4 @@ -import { globToRegExp } from "./glob"; +import { globToRegExp, isBalancedCurlyBrackets, isValidGlobExpression } from "./glob"; describe("globToRegExp()", () => { it("glob = ", () => { @@ -24,19 +24,95 @@ describe("globToRegExp()", () => { expect(globToRegExp("/foo/*")).toBe("\\/foo\\/.*"); }); - it("glob = /*.{jpg}", () => { - expect(globToRegExp("/*.{jpg}")).toBe("\\/.*(jpg)"); + it("glob = /*.{ext}", () => { + expect(globToRegExp("/*.{ext}")).toBe("\\/.*(ext)"); }); - it("glob = /*.{jpg,gif}", () => { - expect(globToRegExp("/*.{jpg,gif}")).toBe("\\/.*(jpg|gif)"); + it("glob = /*.{ext,gif}", () => { + expect(globToRegExp("/*.{ext,gif}")).toBe("\\/.*(ext|gif)"); }); - it("glob = /foo/*.{jpg,gif}", () => { - expect(globToRegExp("/foo/*.{jpg,gif}")).toBe("\\/foo\\/.*(jpg|gif)"); + it("glob = /foo/*.{ext,gif}", () => { + expect(globToRegExp("/foo/*.{ext,gif}")).toBe("\\/foo\\/.*(ext|gif)"); }); it("glob = {foo,bar}.json", () => { expect(globToRegExp("{foo,bar}.json")).toBe("(foo|bar).json"); }); }); + +// describe isValidGlobExpression + +describe("isValidGlobExpression()", () => { + // valid expressions + ["*", "/*", "/foo/*", "/foo/*.ext", "/*.ext", "*.ext", "/foo/*.ext", "/foo/*.{ext}", "/foo/*.{ext,ext}"].forEach((glob) => { + describe("should be TRUE for the following values", () => { + it(`glob = ${glob}`, () => { + expect(isValidGlobExpression(glob)).toBe(true); + }); + }); + }); + + // invalid expressions + [ + undefined, + "", + "*.*", + "**.*", + "**.**", + "**", + "*/*", + "*/*.ext", + "*.ext/*", + "*/*.ext*", + "*.ext/*.ext", + "/blog/*/management", + "/foo/*.{ext,,,,}", + "/foo/*.{ext,", + "/foo/*.ext,}", + "/foo/*.ext}", + "/foo/*.{}", + "/foo/*.", + "/foo/.", + ].forEach((glob) => { + describe("should be FALSE for the following values", () => { + it(`glob = ${glob}`, () => { + expect(isValidGlobExpression(glob)).toBe(false); + }); + }); + }); +}); + +describe("isBalancedCurlyBrackets()", () => { + it("should be true for {}", () => { + expect(isBalancedCurlyBrackets("{,,,}")).toBe(true); + }); + + it("should be true for {}{}{}", () => { + expect(isBalancedCurlyBrackets("{,,,}{,,,}{,,,}")).toBe(true); + }); + + it("should be true for {{}}", () => { + expect(isBalancedCurlyBrackets("{,,,{,,,},,,}")).toBe(true); + }); + + it("should be false for }{", () => { + expect(isBalancedCurlyBrackets("},,,{")).toBe(false); + }); + + it("should be false for }{}{", () => { + expect(isBalancedCurlyBrackets("},,,{,,,},,,{")).toBe(false); + }); + + it("should be false for {", () => { + expect(isBalancedCurlyBrackets("{,,,")).toBe(false); + }); + + it("should be false for }", () => { + expect(isBalancedCurlyBrackets(",,,}")).toBe(false); + }); + + it("should be false for {}}{{}", () => { + expect(isBalancedCurlyBrackets("{,,,}},,,{{,,,}")).toBe(false); + }); +}); diff --git a/src/core/utils/glob.ts b/src/core/utils/glob.ts index 5e2ae658..7c0f8866 100644 --- a/src/core/utils/glob.ts +++ b/src/core/utils/glob.ts @@ -2,7 +2,10 @@ import chalk from "chalk"; import { logger } from "./logger"; /** - * Turn expression into a valid regex + * Turn expression into a valid regexp + * + * @param glob A string containing a valid wildcard expression + * @returns a string containing a valid RegExp */ export function globToRegExp(glob: string | undefined) { logger.silly(`turning glob expression into valid RegExp`); @@ -25,3 +28,88 @@ export function globToRegExp(glob: string | undefined) { return glob.replace(/\//g, "\\/").replace("*.", ".*").replace("/*", "/.*"); } + +/** + * Check if the route rule contains a valid wildcard expression + * + * @param glob A string containing a valid wildcard expression + * @returns true if the glob expression is valid, false otherwise + * @see https://docs.microsoft.com/azure/static-web-apps/configuration#wildcards + */ +export function isValidGlobExpression(glob: string | undefined) { + logger.silly(`checking if glob expression is valid`); + logger.silly(` - glob: ${chalk.yellow(glob)}`); + + if (!glob) { + logger.silly(` - glob is empty. Return false`); + return false; + } + + if (glob === "*") { + logger.silly(` - glob is *`); + return true; + } + + const hasWildcard = glob.includes("*"); + + if (hasWildcard) { + const paths = glob.split("*"); + if (paths.length > 2) { + logger.silly(` - glob has more than one wildcard. Return false`); + return false; + } + + const pathBeforeWildcard = paths[0]; + if (pathBeforeWildcard && glob.endsWith("*")) { + logger.silly(` - glob ends with *. Return true`); + return true; + } + + const pathAfterWildcard = paths[1]; + if (pathAfterWildcard) { + logger.silly(` - pathAfterWildcard: ${chalk.yellow(pathAfterWildcard)}`); + + if (isBalancedCurlyBrackets(glob) === false) { + logger.silly(` - pathAfterWildcard contains unbalanced { } syntax. Return false`); + return false; + } + + // match exactly extensions of type: + // --> /blog/*.html + // --> /blog/*.{html,jpg} + const filesExtensionMatch = pathAfterWildcard.match(/\.(\w+|\{\w+(,\w+)*\})$/); + + if (filesExtensionMatch) { + logger.silly(` - pathAfterWildcard match a file extension. Return true`); + return true; + } else { + logger.silly(` - pathAfterWildcard doesn't match a file extension. Return false`); + return false; + } + } + } + + return false; +} + +/** + * Checks if a string expression has balanced curly brackets + * + * @param str the string expression to be checked + * @returns true if the string expression has balanced curly brackets, false otherwise + */ +export function isBalancedCurlyBrackets(str: string) { + const stack = []; + for (let i = 0; i < str.length; i++) { + const char = str[i]; + if (char === "{") { + stack.push(char); + } else if (char === "}") { + if (stack.length === 0) { + return false; + } + stack.pop(); + } + } + return stack.length === 0; +} diff --git a/src/msha/routes-engine/route-processor.ts b/src/msha/routes-engine/route-processor.ts index 2d09e799..8bb49362 100644 --- a/src/msha/routes-engine/route-processor.ts +++ b/src/msha/routes-engine/route-processor.ts @@ -3,7 +3,7 @@ import chalk from "chalk"; import { DEFAULT_CONFIG } from "../../config"; import { logger } from "../../core"; import { AUTH_STATUS, SWA_CLI_APP_PROTOCOL } from "../../core/constants"; -import { globToRegExp } from "../../core/utils/glob"; +import { globToRegExp, isValidGlobExpression } from "../../core/utils/glob"; import { getIndexHtml } from "./rules/routes"; export function doesRequestPathMatchRoute( @@ -46,7 +46,7 @@ export function doesRequestPathMatchRoute( return true; } - // Since this is a file request, return now, since tring to get a match by appending /index.html doesn't apply here + // Since this is a file request, return now, since we are trying to get a match by appending /index.html doesn't apply here if (!route) { logger.silly(` - route: ${chalk.yellow(route || "")}`); logger.silly(` - match: ${chalk.yellow(false)}`); @@ -100,11 +100,17 @@ function doesRequestPathMatchWildcardRoute(requestPath: string, requestPathFileW // before processing regexp which might be expensive // let's check first if both path and rule start with the same substring if (pathBeforeWildcard && requestPath.startsWith(pathBeforeWildcard) === false) { - logger.silly(` - substring don't match. Exit`); + logger.silly(` - base path doesn't match. Exit`); return false; } + // also, let's check if the route rule doesn't contains a wildcard in the middle of the path + if (isValidGlobExpression(requestPathFileWithWildcard) === false) { + logger.silly(` - route rule contains a wildcard in the middle of the path. Exit`); + return false; + } + // we don't support full globs in the config file. // add this little utility to convert a wildcard into a valid glob pattern const regexp = new RegExp(`^${globToRegExp(requestPathFileWithWildcard)}$`); diff --git a/src/msha/routes-engine/rules/navigation-fallback.ts b/src/msha/routes-engine/rules/navigation-fallback.ts index 397e28b5..f21ff434 100644 --- a/src/msha/routes-engine/rules/navigation-fallback.ts +++ b/src/msha/routes-engine/rules/navigation-fallback.ts @@ -3,7 +3,7 @@ import fs from "fs"; import type http from "http"; import path from "path"; import { logger } from "../../../core"; -import { globToRegExp } from "../../../core/utils/glob"; +import { globToRegExp, isValidGlobExpression } from "../../../core/utils/glob"; import { AUTH_STATUS } from "../../../core/constants"; import { doesRequestPathMatchRoute } from "../route-processor"; import { getIndexHtml } from "./routes"; @@ -50,12 +50,18 @@ export function navigationFallback(req: http.IncomingMessage, res: http.ServerRe // parse the exclusion rules and match at least one rule const isMatchedExcludeRule = navigationFallback?.exclude?.some((filter) => { + if (isValidGlobExpression(filter) === false) { + logger.silly(` - invalid rule ${chalk.yellow(filter)}`); + logger.silly(` - mark as no match`); + return false; + } + // we don't support full globs in the config file. // add this little utility to convert a wildcard into a valid glob pattern const regexp = new RegExp(`^${globToRegExp(filter)}$`); const isMatch = regexp.test(originlUrl!); - logger.silly(` - exclude: ${chalk.yellow(filter)}`); + logger.silly(` - rule: ${chalk.yellow(filter)}`); logger.silly(` - regexp: ${chalk.yellow(regexp)}`); logger.silly(` - isRegexpMatch: ${chalk.yellow(isMatch)}`);