diff --git a/packages/validators/.eslintrc b/packages/validators/.eslintrc index 53efb05..51fda23 100644 --- a/packages/validators/.eslintrc +++ b/packages/validators/.eslintrc @@ -1,9 +1,10 @@ { "extends": ["opengovsg"], "ignorePatterns": ["dist/**/*", "vitest.config.ts"], - "plugins": ["import"], + "plugins": ["import", "eslint-plugin-tsdoc"], "rules": { - "import/no-unresolved": "error" + "import/no-unresolved": "error", + "tsdoc/syntax": "error" }, "parser": "@typescript-eslint/parser", "parserOptions": { diff --git a/packages/validators/package.json b/packages/validators/package.json index 5601984..55e0b10 100644 --- a/packages/validators/package.json +++ b/packages/validators/package.json @@ -25,6 +25,7 @@ "eslint-import-resolver-typescript": "^3.6.1", "eslint-plugin-import": "^2.29.1", "eslint-plugin-simple-import-sort": "^10.0.0", + "eslint-plugin-tsdoc": "^0.3.0", "prettier": "^2.8.4", "tsup": "^8.1.0", "typescript": "^5.4.5" diff --git a/packages/validators/src/url/index.ts b/packages/validators/src/url/index.ts index c63fba9..39fcb98 100644 --- a/packages/validators/src/url/index.ts +++ b/packages/validators/src/url/index.ts @@ -5,9 +5,28 @@ import { OptionsError, UrlValidationError } from '@/url/errors' import { defaultOptions, Options, optionsSchema } from '@/url/options' import { createUrlSchema } from '@/url/schema' +/** + * Validates URLs against a whitelist of allowed protocols and hostnames, preventing open redirects, XSS, SSRF, and other security vulnerabilities. + */ export class UrlValidator { private schema + /** + * Creates a new UrlValidator instance. If no options are provided, the validator will use the default options: + * + * ```ts + * { + * whitelist: { + * protocols: ['http', 'https'], + * }, + * } + * ``` + * + * @param options - The options to use for validation + * @throws {@link OptionsError} If the options are invalid + * + * @public + */ constructor(options: Options = defaultOptions) { const result = optionsSchema.safeParse({ ...defaultOptions, ...options }) if (result.success) { @@ -17,6 +36,15 @@ export class UrlValidator { throw new OptionsError(fromError(result.error).toString()) } + /** + * Parses a URL string. + * + * @param url - The URL to validate + * @returns The URL object if the URL is valid + * @throws {@link UrlValidationError} If the URL is invalid + * + * @public + */ parse(url: string): URL { const result = this.schema.safeParse(url) if (result.success) { diff --git a/packages/validators/src/url/options.ts b/packages/validators/src/url/options.ts index c8a0ce9..f352c53 100644 --- a/packages/validators/src/url/options.ts +++ b/packages/validators/src/url/options.ts @@ -7,7 +7,17 @@ export const defaultOptions = { } export const whitelistSchema = z.object({ + /** + * The list of allowed protocols. + * Caution: allowing `javascript` or `data` protocols can lead to XSS vulnerabilities. + * + * @defaultValue ['http', 'https'] + */ protocols: z.array(z.string()).default(defaultOptions.whitelist.protocols), + /** + * The list of allowed hostnames. + * It is recommended to provide a list of allowed hostnames to prevent open redirects. + */ hosts: z.array(z.string()).optional(), }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 384f0da..2c47a9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: eslint-plugin-simple-import-sort: specifier: ^10.0.0 version: 10.0.0(eslint@8.57.0) + eslint-plugin-tsdoc: + specifier: ^0.3.0 + version: 0.3.0 prettier: specifier: ^2.8.4 version: 2.8.8 @@ -286,6 +289,12 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@microsoft/tsdoc-config@0.17.0': + resolution: {integrity: sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg==} + + '@microsoft/tsdoc@0.15.0': + resolution: {integrity: sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -545,6 +554,9 @@ packages: ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -874,6 +886,9 @@ packages: peerDependencies: eslint: '>=5.0.0' + eslint-plugin-tsdoc@0.3.0: + resolution: {integrity: sha512-0MuFdBrrJVBjT/gyhkP2BqpD0np1NxNLfQ38xXDlSs/KVVpKI2A6vN7jx2Rve/CyUsvOsMGwp9KKrinv7q9g3A==} + eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -1224,6 +1239,9 @@ packages: resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} + jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -1241,6 +1259,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -1520,6 +1541,10 @@ packages: resolution: {integrity: sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==} engines: {node: '>= 0.4'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2134,6 +2159,15 @@ snapshots: '@jridgewell/sourcemap-codec': 1.4.15 optional: true + '@microsoft/tsdoc-config@0.17.0': + dependencies: + '@microsoft/tsdoc': 0.15.0 + ajv: 8.12.0 + jju: 1.4.0 + resolve: 1.22.8 + + '@microsoft/tsdoc@0.15.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -2422,6 +2456,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + ansi-regex@5.0.1: {} ansi-regex@6.0.1: {} @@ -2859,6 +2900,11 @@ snapshots: dependencies: eslint: 8.57.0 + eslint-plugin-tsdoc@0.3.0: + dependencies: + '@microsoft/tsdoc': 0.15.0 + '@microsoft/tsdoc-config': 0.17.0 + eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -3263,6 +3309,8 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jju@1.4.0: {} + joycon@3.1.1: {} js-tokens@4.0.0: {} @@ -3275,6 +3323,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -3533,6 +3583,8 @@ snapshots: define-properties: 1.2.1 set-function-name: 2.0.1 + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {}