diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 3918429e4e..154f190f53 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -95,7 +95,7 @@ describe('/shared-links', () => { expect(resp.status).toBe(200); expect(resp.header['content-type']).toContain('text/html'); expect(resp.text).toContain( - `<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`, + `<meta name="description" content="${metadataAlbum.assets.length} shared photos & videos" />`, ); }); @@ -103,14 +103,14 @@ describe('/shared-links', () => { const resp = await request(shareUrl).get(`/${linkWithAlbum.key}`); expect(resp.status).toBe(200); expect(resp.header['content-type']).toContain('text/html'); - expect(resp.text).toContain(`<meta name="description" content="0 shared photos & videos" />`); + expect(resp.text).toContain(`<meta name="description" content="0 shared photos & videos" />`); }); it('should have correct asset count in meta tag for shared asset', async () => { const resp = await request(shareUrl).get(`/${linkWithAssets.key}`); expect(resp.status).toBe(200); expect(resp.header['content-type']).toContain('text/html'); - expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`); + expect(resp.text).toContain(`<meta name="description" content="1 shared photos & videos" />`); }); it('should have fqdn og:image meta tag for shared asset', async () => { diff --git a/server/package-lock.json b/server/package-lock.json index 8a2c0c9f25..80de8b37ff 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -59,6 +59,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", + "sanitize-html": "^2.14.0", "semver": "^7.6.2", "sharp": "^0.33.0", "sirv": "^3.0.0", @@ -91,6 +92,7 @@ "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", "@types/react": "^19.0.0", + "@types/sanitize-html": "^2.13.0", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", @@ -6042,6 +6044,16 @@ "@types/node": "*" } }, + "node_modules/@types/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, "node_modules/@types/semver": { "version": "7.5.8", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", @@ -8931,7 +8943,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10747,6 +10758,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -12394,6 +12414,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parse5": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", @@ -14072,6 +14098,20 @@ "truncate-utf8-bytes": "^1.0.0" } }, + "node_modules/sanitize-html": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.14.0.tgz", + "integrity": "sha512-CafX+IUPxZshXqqRaG9ZClSlfPVjSxI0td7n07hk8QO2oO+9JDnlcL8iM8TWeOXOIBFgIOx6zioTzM53AOMn3g==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, "node_modules/scheduler": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", diff --git a/server/package.json b/server/package.json index 06c23f3a1e..2d3356bb2c 100644 --- a/server/package.json +++ b/server/package.json @@ -85,6 +85,7 @@ "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", + "sanitize-html": "^2.14.0", "semver": "^7.6.2", "sharp": "^0.33.0", "sirv": "^3.0.0", @@ -117,6 +118,7 @@ "@types/picomatch": "^3.0.0", "@types/pngjs": "^6.0.5", "@types/react": "^19.0.0", + "@types/sanitize-html": "^2.13.0", "@types/semver": "^7.5.8", "@types/supertest": "^6.0.0", "@types/ua-parser-js": "^0.7.36", diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index a18f863f99..71fb36b4f2 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; import { NextFunction, Request, Response } from 'express'; import { readFileSync } from 'node:fs'; +import sanitizeHtml from 'sanitize-html'; import { ONE_HOUR } from 'src/constants'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; @@ -12,21 +13,25 @@ import { VersionService } from 'src/services/version.service'; import { OpenGraphTags } from 'src/utils/misc'; const render = (index: string, meta: OpenGraphTags) => { + const [title, description, imageUrl] = [meta.title, meta.description, meta.imageUrl].map((item) => + item ? sanitizeHtml(item, { allowedTags: [] }) : '', + ); + const tags = ` - <meta name="description" content="${meta.description}" /> + <meta name="description" content="${description}" /> <!-- Facebook Meta Tags --> <meta property="og:type" content="website" /> - <meta property="og:title" content="${meta.title}" /> - <meta property="og:description" content="${meta.description}" /> - ${meta.imageUrl ? `<meta property="og:image" content="${meta.imageUrl}" />` : ''} + <meta property="og:title" content="${title}" /> + <meta property="og:description" content="${description}" /> + ${imageUrl ? `<meta property="og:image" content="${imageUrl}" />` : ''} <!-- Twitter Meta Tags --> <meta name="twitter:card" content="summary_large_image" /> - <meta name="twitter:title" content="${meta.title}" /> - <meta name="twitter:description" content="${meta.description}" /> + <meta name="twitter:title" content="${title}" /> + <meta name="twitter:description" content="${description}" /> - ${meta.imageUrl ? `<meta name="twitter:image" content="${meta.imageUrl}" />` : ''}`; + ${imageUrl ? `<meta name="twitter:image" content="${imageUrl}" />` : ''}`; return index.replace('<!-- metadata:tags -->', tags); };