Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 82 additions & 81 deletions apps/web/functions/components/metaTagInjector.test.ts
Original file line number Diff line number Diff line change
@@ -1,93 +1,94 @@
import { MetaTagInjector } from './metaTagInjector'

test('should append meta tag to element', () => {
const element = {
append: jest.fn(),
} as unknown as Element
const property = 'property'
const content = 'content'
const injector = new MetaTagInjector(
{
title: 'test',
url: 'testUrl',
image: 'testImage',
description: 'testDescription',
},
new Request('http://localhost'),
)
injector.appendProperty(element, property, content)
expect(element.append).toHaveBeenCalledWith(`<meta property="${property}" content="${content}" data-rh="true">`, {
html: true,
})
describe('MetaTagInjector', () => {
let element: HTMLElement
let injector: MetaTagInjector

injector.element(element)
expect(element.append).toHaveBeenCalledWith(`<meta property="og:title" content="test" data-rh="true">`, {
html: true,
})
expect(element.append).toHaveBeenCalledWith(`<meta name="description" content="testDescription" data-rh="true">`, {
html: true,
})
expect(element.append).toHaveBeenCalledWith(
`<meta property="og:description" content="testDescription" data-rh="true">`,
{
html: true,
},
)
expect(element.append).toHaveBeenCalledWith(`<meta property="og:image" content="testImage" data-rh="true">`, {
html: true,
})
expect(element.append).toHaveBeenCalledWith(`<meta property="og:image:width" content="1200" data-rh="true">`, {
html: true,
})
expect(element.append).toHaveBeenCalledWith(`<meta property="og:image:height" content="630" data-rh="true">`, {
html: true,
})
expect(element.append).toHaveBeenCalledWith(`<meta property="og:image:alt" content="test" data-rh="true">`, {
html: true,
})
expect(element.append).toHaveBeenCalledWith(`<meta property="og:type" content="website" data-rh="true">`, {
html: true,
})
expect(element.append).toHaveBeenCalledWith(`<meta property="og:url" content="testUrl" data-rh="true">`, {
html: true,
const metaData = {
title: 'test',
url: 'testUrl',
image: 'testImage',
description: 'testDescription',
}

beforeEach(() => {
element = {
append: jest.fn(),
} as unknown as HTMLElement

injector = new MetaTagInjector(metaData, new Request('http://localhost'))
})

expect(element.append).toHaveBeenCalledWith(
`<meta property="twitter:card" content="summary_large_image" data-rh="true">`,
{
html: true,
},
)
expect(element.append).toHaveBeenCalledWith(`<meta property="twitter:title" content="test" data-rh="true">`, {
html: true,
test('should append individual meta tag correctly', () => {
const property = 'property'
const content = 'content'

injector.appendProperty(element, property, content)

expect(element.append).toHaveBeenCalledWith(
`<meta property="${property}" content="${content}" data-rh="true">`,
{ html: true }
)
})
expect(element.append).toHaveBeenCalledWith(`<meta property="twitter:image" content="testImage" data-rh="true">`, {
html: true,

test('should append all required meta tags to the element', () => {
injector.element(element)

const expectedTags = [
`<meta property="og:title" content="test" data-rh="true">`,
`<meta name="description" content="testDescription" data-rh="true">`,
`<meta property="og:description" content="testDescription" data-rh="true">`,
`<meta property="og:image" content="testImage" data-rh="true">`,
`<meta property="og:image:width" content="1200" data-rh="true">`,
`<meta property="og:image:height" content="630" data-rh="true">`,
`<meta property="og:image:alt" content="test" data-rh="true">`,
`<meta property="og:type" content="website" data-rh="true">`,
`<meta property="og:url" content="testUrl" data-rh="true">`,
`<meta property="twitter:card" content="summary_large_image" data-rh="true">`,
`<meta property="twitter:title" content="test" data-rh="true">`,
`<meta property="twitter:image" content="testImage" data-rh="true">`,
`<meta property="twitter:image:alt" content="test" data-rh="true">`,
]

expectedTags.forEach((tag) => {
expect(element.append).toHaveBeenCalledWith(tag, { html: true })
})

expect(element.append).toHaveBeenCalledTimes(expectedTags.length)
})
expect(element.append).toHaveBeenCalledWith(`<meta property="twitter:image:alt" content="test" data-rh="true">`, {
html: true,

test('should append x-blocked-paths meta if present in headers', () => {
const blockedRequest = new Request('http://localhost')
blockedRequest.headers.set('x-blocked-paths', '/')
const blockedInjector = new MetaTagInjector(metaData, blockedRequest)

blockedInjector.element(element)

expect(element.append).toHaveBeenCalledWith(
`<meta property="x:blocked-paths" content="/" data-rh="true">`,
{ html: true }
)
})

expect(element.append).toHaveBeenCalledTimes(14)
})
test('should prevent potential XSS via meta content', () => {
const unsafeMetaData = {
title: `<script>alert("xss")</script>`,
url: 'https://safe.com',
image: 'img.jpg',
description: 'test',
}

const xssInjector = new MetaTagInjector(unsafeMetaData, new Request('http://localhost'))
const xssElement = {
append: jest.fn(),
} as unknown as HTMLElement

xssInjector.element(xssElement)

// Assert it does not include unescaped script tag
const calls = (xssElement.append as jest.Mock).mock.calls
const scriptInjectionDetected = calls.some(([tag]) => tag.includes('<script>'))

test('should pass through header blocked paths', () => {
const element = {
append: jest.fn(),
} as unknown as Element
const request = new Request('http://localhost')
request.headers.set('x-blocked-paths', '/')
const injector = new MetaTagInjector(
{
title: 'test',
url: 'testUrl',
image: 'testImage',
description: 'testDescription',
},
request,
)
injector.element(element)
expect(element.append).toHaveBeenCalledWith(`<meta property="x:blocked-paths" content="/" data-rh="true">`, {
html: true,
expect(scriptInjectionDetected).toBe(false)
})
})
82 changes: 54 additions & 28 deletions apps/web/functions/components/metaTagInjector.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,78 @@
import { MetaTagInjectorInput } from 'shared-cloud/metatags'

/**
* Listener class for Cloudflare's HTMLRewriter {@link https://developers.cloudflare.com/workers/runtime-apis/html-rewriter}
* to inject meta tags into the <head> of an HTML document.
* Injects meta tags into the <head> element using Cloudflare's HTMLRewriter.
* @see https://developers.cloudflare.com/workers/runtime-apis/html-rewriter
*/
export class MetaTagInjector implements HTMLRewriterElementContentHandlers {
static SELECTOR = 'head'

constructor(
private input: MetaTagInjectorInput,
private request: Request,
private readonly input: MetaTagInjectorInput,
private readonly request: Request,
) {}

append(element: Element, attribute: string, content: string) {
// without adding data-rh="true", react-helmet-async doesn't overwrite existing metatags
element.append(`<meta ${attribute} content="${content}" data-rh="true">`, { html: true })
/**
* Appends a meta tag to the element.
* @param element - The HTML element to append to.
* @param attribute - Meta tag attribute (e.g., property="og:title").
* @param content - Meta tag content.
*/
private append(element: Element, attribute: string, content: string) {
const safeContent = this.escape(content)
element.append(`<meta ${attribute} content="${safeContent}" data-rh="true">`, { html: true })
}

appendProperty(element: Element, property: string, content: string) {
this.append(element, `property="${property}"`, content)
/**
* Appends a property-based meta tag.
* @param element - The HTML element to append to.
* @param property - The OpenGraph or Twitter meta property.
* @param content - The content of the property.
*/
private appendProperty(element: Element, property: string, content: string) {
if (content) {
this.append(element, `property="${property}"`, content)
}
}

element(element: Element) {
if (this.input.description) {
this.append(element, `name="description"`, this.input.description)
}
/**
* Escapes special HTML characters in content.
*/
private escape(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}

//Open Graph Tags
this.appendProperty(element, 'og:title', this.input.title)
if (this.input.description) {
this.appendProperty(element, 'og:description', this.input.description)
/**
* Injects all required meta tags into the <head> of the HTML document.
*/
element(element: Element): void {
const { title, description, image, url } = this.input

if (description) {
this.append(element, 'name="description"', description)
this.appendProperty(element, 'og:description', description)
}
if (this.input.image) {
this.appendProperty(element, 'og:image', this.input.image)

this.appendProperty(element, 'og:title', title)
this.appendProperty(element, 'og:type', 'website')
this.appendProperty(element, 'og:url', url)

if (image) {
this.appendProperty(element, 'og:image', image)
this.appendProperty(element, 'og:image:width', '1200')
this.appendProperty(element, 'og:image:height', '630')
this.appendProperty(element, 'og:image:alt', this.input.title)
this.appendProperty(element, 'og:image:alt', title)

this.appendProperty(element, 'twitter:image', image)
this.appendProperty(element, 'twitter:image:alt', title)
}
this.appendProperty(element, 'og:type', 'website')
this.appendProperty(element, 'og:url', this.input.url)

//Twitter Tags
this.appendProperty(element, 'twitter:card', 'summary_large_image')
this.appendProperty(element, 'twitter:title', this.input.title)
if (this.input.image) {
this.appendProperty(element, 'twitter:image', this.input.image)
this.appendProperty(element, 'twitter:image:alt', this.input.title)
}
this.appendProperty(element, 'twitter:title', title)

const blockedPaths = this.request.headers.get('x-blocked-paths')
if (blockedPaths) {
Expand Down