Skip to content

Commit 9c76e19

Browse files
authored
Merge pull request #2324 from Giveth/security/xss-sanitize-project-updates
Sanitize project update HTML to prevent stored XSS
2 parents 870dcbc + cfcc94d commit 9c76e19

9 files changed

Lines changed: 888 additions & 23 deletions

File tree

package-lock.json

Lines changed: 313 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"patch-package": "^6.5.1",
6666
"rate-limit-redis": "^4.2.0",
6767
"reflect-metadata": "^0.1.13",
68+
"sanitize-html": "^2.17.4",
6869
"siwe": "^3.0.0",
6970
"slugify": "^1.4.7",
7071
"stripe": "^8.137.0",
@@ -96,6 +97,7 @@
9697
"@types/mocha": "^8.2.1",
9798
"@types/node": "^14.14.31",
9899
"@types/node-cron": "^3.0.0",
100+
"@types/sanitize-html": "^2.16.1",
99101
"@typescript-eslint/eslint-plugin": "^7.2.0",
100102
"@typescript-eslint/parser": "^7.2.0",
101103
"chai": "^4.3.0",
@@ -273,4 +275,4 @@
273275
}
274276
},
275277
"license": "ISC"
276-
}
278+
}

src/entities/project.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Field, Float, ID, ObjectType } from 'type-graphql';
22
import {
33
AfterInsert,
4+
AfterLoad,
45
AfterUpdate,
56
BaseEntity,
67
BeforeInsert,
@@ -32,6 +33,7 @@ import { findUserById } from '../repositories/userRepository';
3233
import { EstimatedMatchingByQfRound } from '../types/qfTypes';
3334
import { i18n, translationErrorMessagesKeys } from '../utils/errorMessages';
3435
import { getHtmlTextSummary } from '../utils/utils';
36+
import { sanitizeProjectRichText } from '../utils/htmlSanitizer';
3537
import { ProjectFuturePowerView } from '../views/projectFuturePowerView';
3638
import { ProjectInstantPowerView } from '../views/projectInstantPowerView';
3739
import { ProjectPowerView } from '../views/projectPowerView';
@@ -640,6 +642,16 @@ export class Project extends BaseEntity {
640642
this.descriptionSummary = getHtmlTextSummary(this.description);
641643
}
642644

645+
// Defense in depth for rows persisted before sanitize-on-write was added.
646+
// Sanitizing here is idempotent on already-clean content, so newly written
647+
// rows pay only a small CPU cost on read.
648+
@AfterLoad()
649+
sanitizeProjectDescriptionOnLoad() {
650+
if (this.description) {
651+
this.description = sanitizeProjectRichText(this.description);
652+
}
653+
}
654+
643655
// we should not expose this field to the client
644656
@Column('text', { nullable: true })
645657
fundingPoolHdPath?: string;
@@ -839,6 +851,16 @@ export class ProjectUpdate extends BaseEntity {
839851
setProjectUpdateContentSummary() {
840852
this.contentSummary = getHtmlTextSummary(this.content);
841853
}
854+
855+
// Defense in depth for rows persisted before sanitize-on-write was added
856+
// (the original stored-XSS path was via featuredProjectUpdate.content).
857+
// Sanitizing here is idempotent on already-clean content.
858+
@AfterLoad()
859+
sanitizeProjectUpdateContentOnLoad() {
860+
if (this.content) {
861+
this.content = sanitizeProjectRichText(this.content);
862+
}
863+
}
842864
}
843865

844866
@ObjectType()

src/resolvers/causeResolver.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ import {
3939
creteSlugFromProject,
4040
titleWithoutSpecialCharacters,
4141
} from '../utils/utils';
42+
import {
43+
getRichTextPlainLength,
44+
sanitizeProjectRichText,
45+
} from '../utils/htmlSanitizer';
46+
import { PROJECT_DESCRIPTION_MAX_LENGTH } from '../constants/validators';
4247
import { Category } from '../entities/category';
4348
import { Organization, ORGANIZATION_LABELS } from '../entities/organization';
4449
import { ProjectStatus } from '../entities/projectStatus';
@@ -262,6 +267,12 @@ export class CauseResolver {
262267
i18n.__(translationErrorMessagesKeys.YOU_ARE_NOT_THE_OWNER_OF_PROJECT),
263268
);
264269

270+
if (newProjectData.description) {
271+
newProjectData.description = sanitizeProjectRichText(
272+
newProjectData.description,
273+
);
274+
}
275+
265276
for (const field in newProjectData) {
266277
if (field === 'addresses' || field === 'socialMedia') {
267278
// We will take care of addresses and relations manually
@@ -477,6 +488,16 @@ export class CauseResolver {
477488
throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_INPUT));
478489
}
479490

491+
if (
492+
getRichTextPlainLength(description) > PROJECT_DESCRIPTION_MAX_LENGTH
493+
) {
494+
throw new Error(
495+
i18n.__(
496+
translationErrorMessagesKeys.PROJECT_DESCRIPTION_LENGTH_SIZE_EXCEEDED,
497+
),
498+
);
499+
}
500+
480501
// Validate chainId is a polygon chain id
481502
if (typeof chainId !== 'number' || isNaN(chainId) || chainId !== 137) {
482503
throw new Error(i18n.__(translationErrorMessagesKeys.INVALID_CHAIN_ID));
@@ -592,7 +613,7 @@ export class CauseResolver {
592613

593614
const causeData = {
594615
title: convert(title.trim()),
595-
description: description.trim(),
616+
description: sanitizeProjectRichText(description.trim()),
596617
chainId,
597618
slug,
598619
slugHistory: [],

src/resolvers/projectResolver.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6298,6 +6298,61 @@ function editProjectUpdateTestCases() {
62986298
errorMessages.AUTHENTICATION_REQUIRED,
62996299
);
63006300
});
6301+
it('should strip XSS payloads from edited project update content', async () => {
6302+
const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress());
6303+
const accessToken = await generateTestAccessToken(user.id);
6304+
const project = await saveProjectDirectlyToDb({
6305+
...createProjectData(),
6306+
adminUserId: user.id,
6307+
});
6308+
const updateProject = await ProjectUpdate.create({
6309+
userId: user.id,
6310+
projectId: project.id,
6311+
content: 'safe original',
6312+
title: 'safe original title',
6313+
createdAt: new Date(),
6314+
isMain: false,
6315+
}).save();
6316+
6317+
const maliciousContent =
6318+
'<p>hello</p><script>alert(1)</script><img src="x" onerror="alert(2)"><a href="javascript:alert(3)">x</a>';
6319+
6320+
const result = await axios.post(
6321+
graphqlUrl,
6322+
{
6323+
query: editProjectUpdateQuery,
6324+
variables: {
6325+
updateId: updateProject.id,
6326+
content: maliciousContent,
6327+
title: 'still safe title',
6328+
},
6329+
},
6330+
{
6331+
headers: {
6332+
Authorization: `Bearer ${accessToken}`,
6333+
},
6334+
},
6335+
);
6336+
6337+
const returned = result.data.data.editProjectUpdate.content;
6338+
assert.notInclude(returned, '<script');
6339+
assert.notInclude(returned, 'onerror');
6340+
assert.notInclude(returned, 'javascript:');
6341+
assert.include(returned, '<p>hello</p>');
6342+
6343+
// Persistence-level check: the @AfterLoad hook also sanitizes on read,
6344+
// so a regression that skipped write-time sanitization could be hidden
6345+
// by the GraphQL response. Read the row directly from the database to
6346+
// verify the payload was sanitized *before* being persisted.
6347+
const [storedRow] = await AppDataSource.getDataSource().query(
6348+
'SELECT content FROM public.project_update WHERE id = $1',
6349+
[updateProject.id],
6350+
);
6351+
assert.notInclude(storedRow.content, '<script');
6352+
assert.notInclude(storedRow.content, 'onerror');
6353+
assert.notInclude(storedRow.content, 'javascript:');
6354+
assert.include(storedRow.content, '<p>hello</p>');
6355+
});
63016356
}
63026357

63036358
function deleteProjectUpdateTestCases() {

src/resolvers/projectResolver.ts

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ import {
9797
} from '../repositories/projectRepository';
9898
import { sortTokensByOrderAndAlphabets } from '../utils/tokenUtils';
9999
import { getNotificationAdapter } from '../adapters/adaptersFactory';
100+
import {
101+
getRichTextPlainLength,
102+
sanitizeProjectRichText,
103+
} from '../utils/htmlSanitizer';
100104
import { NETWORK_IDS } from '../provider';
101105
import { getVerificationFormStatusByProjectId } from '../repositories/projectVerificationRepository';
102106
import {
@@ -114,7 +118,10 @@ import { creteSlugFromProject, isSocialMediaEqual } from '../utils/utils';
114118
import { findCampaignBySlug } from '../repositories/campaignRepository';
115119
import { Campaign } from '../entities/campaign';
116120
import { FeaturedUpdate } from '../entities/featuredUpdate';
117-
import { PROJECT_UPDATE_CONTENT_MAX_LENGTH } from '../constants/validators';
121+
import {
122+
PROJECT_DESCRIPTION_MAX_LENGTH,
123+
PROJECT_UPDATE_CONTENT_MAX_LENGTH,
124+
} from '../constants/validators';
118125
import { calculateGivbackFactor } from '../services/givbackService';
119126
import { ProjectBySlugResponse } from './types/projectResolver';
120127
import { ChainType } from '../types/network';
@@ -1508,6 +1515,12 @@ export class ProjectResolver {
15081515
i18n.__(translationErrorMessagesKeys.YOU_ARE_NOT_THE_OWNER_OF_PROJECT),
15091516
);
15101517

1518+
if (newProjectData.description) {
1519+
newProjectData.description = sanitizeProjectRichText(
1520+
newProjectData.description,
1521+
);
1522+
}
1523+
15111524
for (const field in newProjectData) {
15121525
if (field === 'addresses' || field === 'socialMedia') {
15131526
// We will take care of addresses and relations manually
@@ -1774,7 +1787,23 @@ export class ProjectResolver {
17741787
@Ctx() ctx: ApolloContext,
17751788
): Promise<Project> {
17761789
const user = await getLoggedInUser(ctx);
1777-
const { image, description } = projectInput;
1790+
const { image } = projectInput;
1791+
if (projectInput.description) {
1792+
if (
1793+
getRichTextPlainLength(projectInput.description) >
1794+
PROJECT_DESCRIPTION_MAX_LENGTH
1795+
) {
1796+
throw new Error(
1797+
i18n.__(
1798+
translationErrorMessagesKeys.PROJECT_DESCRIPTION_LENGTH_SIZE_EXCEEDED,
1799+
),
1800+
);
1801+
}
1802+
projectInput.description = sanitizeProjectRichText(
1803+
projectInput.description,
1804+
);
1805+
}
1806+
const { description } = projectInput;
17781807

17791808
const dbUser = await findUserById(user.id);
17801809

@@ -1974,17 +2003,16 @@ export class ProjectResolver {
19742003
i18n.__(translationErrorMessagesKeys.AUTHENTICATION_REQUIRED),
19752004
);
19762005

1977-
if (
1978-
content?.replace(/<[^>]+>/g, '')?.length >
1979-
PROJECT_UPDATE_CONTENT_MAX_LENGTH
1980-
) {
2006+
if (getRichTextPlainLength(content) > PROJECT_UPDATE_CONTENT_MAX_LENGTH) {
19812007
throw new Error(
19822008
i18n.__(
19832009
translationErrorMessagesKeys.PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED,
19842010
),
19852011
);
19862012
}
19872013

2014+
const sanitizedContent = sanitizeProjectRichText(content);
2015+
19882016
const owner = await findUserById(user.userId);
19892017

19902018
if (!owner)
@@ -2007,7 +2035,7 @@ export class ProjectResolver {
20072035
const update = ProjectUpdate.create({
20082036
userId: user.userId,
20092037
projectId: project.id,
2010-
content,
2038+
content: sanitizedContent,
20112039
title,
20122040
createdAt: new Date(),
20132041
isMain: false,
@@ -2039,16 +2067,11 @@ export class ProjectResolver {
20392067
throw new Error(
20402068
i18n.__(translationErrorMessagesKeys.AUTHENTICATION_REQUIRED),
20412069
);
2042-
if (
2043-
content?.replace(/<[^>]+>/g, '')?.length >
2044-
PROJECT_UPDATE_CONTENT_MAX_LENGTH
2045-
) {
2046-
throw new Error(
2047-
i18n.__(
2048-
translationErrorMessagesKeys.PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED,
2049-
),
2050-
);
2051-
}
2070+
2071+
// Intentionally no length check here: legacy project_update rows can
2072+
// exceed the current max, and rejecting on edit would block owners from
2073+
// fixing or trimming long content. New content is still capped via
2074+
// addProjectUpdate. (Per RamRamez's review on PR #2324.)
20522075

20532076
const owner = await findUserById(user.userId);
20542077

@@ -2075,7 +2098,7 @@ export class ProjectResolver {
20752098
);
20762099

20772100
update.title = title;
2078-
update.content = content;
2101+
update.content = sanitizeProjectRichText(content);
20792102
await update.save();
20802103
await update.reload();
20812104

src/utils/errorMessages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,8 @@ export const translationErrorMessagesKeys = {
400400
'REGISTERED_NON_PROFITS_CATEGORY_DOESNT_EXIST',
401401
PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED:
402402
'PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED',
403+
PROJECT_DESCRIPTION_LENGTH_SIZE_EXCEEDED:
404+
'PROJECT_DESCRIPTION_LENGTH_SIZE_EXCEEDED',
403405
DRAFT_DONATION_DISABLED: 'DRAFT_DONATION_DISABLED',
404406
DRAFT_RECURRING_DONATION_DISABLED: 'DRAFT_RECURRING_DONATION_DISABLED',
405407
EVM_SUPPORT_ONLY: 'EVM_SUPPORT_ONLY',

0 commit comments

Comments
 (0)