From a5bdcf632afe0c0a609962f0dec258b026aa7c66 Mon Sep 17 00:00:00 2001 From: Laurin Quast Date: Wed, 21 Feb 2024 18:04:35 +0100 Subject: [PATCH] Restrict names to alphanum chars, spaces . , _ - / & and escape HTML in email bodies (#3916) Co-authored-by: Kamil Kisiela --- .../providers/organization-manager.ts | 8 +- .../api/src/modules/organization/resolvers.ts | 22 ++-- .../api/src/modules/project/resolvers.ts | 4 +- .../src/modules/shared/providers/emails.ts | 110 +++++++++++++++++- .../api/src/modules/target/resolvers.ts | 3 +- packages/services/api/src/shared/entities.ts | 7 ++ 6 files changed, 129 insertions(+), 25 deletions(-) diff --git a/packages/services/api/src/modules/organization/providers/organization-manager.ts b/packages/services/api/src/modules/organization/providers/organization-manager.ts index ad7787c66..ea30399b4 100644 --- a/packages/services/api/src/modules/organization/providers/organization-manager.ts +++ b/packages/services/api/src/modules/organization/providers/organization-manager.ts @@ -10,7 +10,7 @@ import { OrganizationAccessScope } from '../../auth/providers/organization-acces import { ProjectAccessScope } from '../../auth/providers/project-access'; import { TargetAccessScope } from '../../auth/providers/target-access'; import { BillingProvider } from '../../billing/providers/billing.provider'; -import { Emails } from '../../shared/providers/emails'; +import { Emails, mjml } from '../../shared/providers/emails'; import { Logger } from '../../shared/providers/logger'; import type { OrganizationSelector } from '../../shared/providers/storage'; import { Storage } from '../../shared/providers/storage'; @@ -534,7 +534,7 @@ export class OrganizationManager { email: createHash('sha256').update(invitation.email).digest('hex'), }), email, - body: ` + body: mjml` @@ -544,7 +544,7 @@ export class OrganizationManager { Someone from ${organization.name} invited you to join GraphQL Hive. . - + Accept the invitation @@ -647,7 +647,7 @@ export class OrganizationManager { await this.emails.schedule({ email: member.user.email, subject: `Organization transfer from ${currentUser.displayName} (${organization.name})`, - body: ` + body: mjml` diff --git a/packages/services/api/src/modules/organization/resolvers.ts b/packages/services/api/src/modules/organization/resolvers.ts index 09421619c..1fb0a829d 100644 --- a/packages/services/api/src/modules/organization/resolvers.ts +++ b/packages/services/api/src/modules/organization/resolvers.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { z } from 'zod'; +import { NameModel } from '../../shared/entities'; import { createConnection } from '../../shared/schema'; import { AuthManager } from '../auth/providers/auth-manager'; import { @@ -14,7 +15,7 @@ import { Logger } from '../shared/providers/logger'; import type { OrganizationModule } from './__generated__/types'; import { OrganizationManager } from './providers/organization-manager'; -const OrganizationNameModel = z.string().min(2).max(50); +const OrganizationNameModel = NameModel.min(2).max(50); const createOrUpdateMemberRoleInputSchema = z.object({ name: z @@ -89,18 +90,13 @@ export const resolvers: OrganizationModule.Resolvers = { }, Mutation: { async createOrganization(_, { input }, { injector }) { - const CreateOrganizationModel = z.object({ - name: OrganizationNameModel, - }); - - const result = CreateOrganizationModel.safeParse(input); - - if (!result.success) { + const organizationNameResult = OrganizationNameModel.safeParse(input.name.trim()); + if (!organizationNameResult.success) { return { error: { message: 'Please check your input.', inputErrors: { - name: result.error.formErrors.fieldErrors.name?.[0], + name: organizationNameResult.error.formErrors.fieldErrors?.[0]?.[0] ?? null, }, }, }; @@ -161,17 +157,13 @@ export const resolvers: OrganizationModule.Resolvers = { }; }, async updateOrganizationName(_, { input }, { injector }) { - const UpdateOrganizationNameModel = z.object({ - name: OrganizationNameModel, - }); - - const result = UpdateOrganizationNameModel.safeParse(input); + const result = OrganizationNameModel.safeParse(input.name?.trim()); if (!result.success) { return { error: { message: - result.error.formErrors.fieldErrors.name?.[0] ?? + result.error.formErrors.fieldErrors?.[0]?.[0] ?? 'Changing the organization name failed.', }, }; diff --git a/packages/services/api/src/modules/project/resolvers.ts b/packages/services/api/src/modules/project/resolvers.ts index 00f2fb75b..78187c39a 100644 --- a/packages/services/api/src/modules/project/resolvers.ts +++ b/packages/services/api/src/modules/project/resolvers.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { ProjectType } from '../../shared/entities'; +import { NameModel, ProjectType } from '../../shared/entities'; import { createConnection } from '../../shared/schema'; import { OrganizationManager } from '../organization/providers/organization-manager'; import { IdTranslator } from '../shared/providers/id-translator'; @@ -7,7 +7,7 @@ import { TargetManager } from '../target/providers/target-manager'; import type { ProjectModule } from './__generated__/types'; import { ProjectManager } from './providers/project-manager'; -const ProjectNameModel = z.string().min(2).max(40); +const ProjectNameModel = NameModel.min(2).max(40); const URLModel = z.string().url().max(500); const MaybeModel = (value: T) => z.union([z.null(), z.undefined(), value]); diff --git a/packages/services/api/src/modules/shared/providers/emails.ts b/packages/services/api/src/modules/shared/providers/emails.ts index 221821b18..2da4b63a5 100644 --- a/packages/services/api/src/modules/shared/providers/emails.ts +++ b/packages/services/api/src/modules/shared/providers/emails.ts @@ -1,5 +1,5 @@ import { Inject, Injectable, InjectionToken, Optional } from 'graphql-modules'; -import type { EmailsApi, EmailsApiInput } from '@hive/emails'; +import type { EmailsApi } from '@hive/emails'; import { createTRPCProxyClient, httpLink } from '@trpc/client'; import { fetch } from '@whatwg-node/fetch'; @@ -22,11 +22,115 @@ export class Emails { : null; } - schedule(input: EmailsApiInput['schedule']) { + schedule(input: { id?: string; email: string; subject: string; body: MJMLValue }) { if (!this.api) { return Promise.resolve(); } - return this.api.schedule.mutate(input); + return this.api.schedule.mutate({ + ...input, + body: input.body.content, + }); } } +export type MJMLValue = { + readonly kind: 'mjml'; + readonly content: string; +}; + +type RawValue = { + readonly kind: 'raw'; + readonly content: string; +}; +type SpecialValues = RawValue; +type ValueExpression = string | SpecialValues; + +function isOfKind(value: unknown, kind: T['kind']): value is T { + return !!value && typeof value === 'object' && 'kind' in value && value.kind === kind; +} + +function isRawValue(value: unknown): value is RawValue { + return isOfKind(value, 'raw'); +} + +export function mjml(parts: TemplateStringsArray, ...values: ValueExpression[]): MJMLValue { + let content = ''; + let index = 0; + + for (const part of parts) { + const token = values[index++]; + + content += part; + + if (index >= parts.length) { + continue; + } + + if (token === undefined) { + throw new Error('MJML tag cannot be bound an undefined value.'); + } else if (isRawValue(token)) { + content += token.content; + } else if (typeof token === 'string') { + content += escapeHtml(token); + } else { + throw new TypeError('mjml: Unexpected value expression.'); + } + } + + return { + kind: 'mjml', + content: content, + }; +} + +mjml.raw = (content: string): RawValue => ({ + kind: 'raw', + content, +}); + +/** + * @source https://github.com/component/escape-html + */ + +function escapeHtml(input: string): string { + const matchHtmlRegExp = /["'<>]/; + const match = matchHtmlRegExp.exec(input); + + if (!match) { + return input; + } + + let escape; + let html = ''; + let index = 0; + let lastIndex = 0; + + for (index = match.index; index < input.length; index++) { + switch (input.charCodeAt(index)) { + case 34: // " + escape = '"'; + break; + break; + case 39: // ' + escape = '''; + break; + case 60: // < + escape = '<'; + break; + case 62: // > + escape = '>'; + break; + default: + continue; + } + + if (lastIndex !== index) { + html += input.substring(lastIndex, index); + } + + lastIndex = index + 1; + html += escape; + } + + return lastIndex !== index ? html + input.substring(lastIndex, index) : html; +} diff --git a/packages/services/api/src/modules/target/resolvers.ts b/packages/services/api/src/modules/target/resolvers.ts index 825116f52..4c970f062 100644 --- a/packages/services/api/src/modules/target/resolvers.ts +++ b/packages/services/api/src/modules/target/resolvers.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { NameModel } from '../../shared/entities'; import { createConnection } from '../../shared/schema'; import { OrganizationManager } from '../organization/providers/organization-manager'; import { ProjectManager } from '../project/providers/project-manager'; @@ -6,7 +7,7 @@ import { IdTranslator } from '../shared/providers/id-translator'; import type { TargetModule } from './__generated__/types'; import { TargetManager } from './providers/target-manager'; -const TargetNameModel = z.string().min(2).max(30); +const TargetNameModel = NameModel.min(2).max(30); const PercentageModel = z.number().min(0).max(100); export const resolvers: TargetModule.Resolvers = { diff --git a/packages/services/api/src/shared/entities.ts b/packages/services/api/src/shared/entities.ts index 79681a115..7500f7864 100644 --- a/packages/services/api/src/shared/entities.ts +++ b/packages/services/api/src/shared/entities.ts @@ -14,6 +14,13 @@ import type { } from '../__generated__/types'; import { parseGraphQLSource, sortDocumentNode } from './schema'; +export const NameModel = z + .string() + .regex( + /^([a-z]|[0-9]|\s|\.|,|_|-|\/|&)+$/i, + `Name restricted to alphanumerical characters, spaces and . , _ - / &`, + ); + export const SingleSchemaModel = z .object({ kind: z.literal('single'),