Restrict names to alphanum chars, spaces . , _ - / & and escape HTML in email bodies (#3916)

Co-authored-by: Kamil Kisiela <kamil.kisiela@gmail.com>
This commit is contained in:
Laurin Quast 2024-02-21 18:04:35 +01:00 committed by GitHub
parent eedb6a9b23
commit a5bdcf632a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 129 additions and 25 deletions

View file

@ -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`
<mjml>
<mj-body>
<mj-section>
@ -544,7 +544,7 @@ export class OrganizationManager {
<mj-text>
Someone from <strong>${organization.name}</strong> invited you to join GraphQL Hive.
</mj-text>.
<mj-button href="${this.appBaseUrl}/join/${invitation.code}">
<mj-button href="${mjml.raw(this.appBaseUrl)}/join/${invitation.code}">
Accept the invitation
</mj-button>
</mj-column>
@ -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`
<mjml>
<mj-body>
<mj-section>

View file

@ -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.',
},
};

View file

@ -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 = <T extends z.ZodType>(value: T) => z.union([z.null(), z.undefined(), value]);

View file

@ -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<T extends SpecialValues>(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<RawValue>(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 = '&quot;';
break;
break;
case 39: // '
escape = '&#39;';
break;
case 60: // <
escape = '&lt;';
break;
case 62: // >
escape = '&gt;';
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;
}

View file

@ -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 = {

View file

@ -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'),