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
17 changes: 14 additions & 3 deletions packages/platform-browser/src/browser/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ export class Meta {
*/
getTag(attrSelector: string): HTMLMetaElement | null {
if (!attrSelector) return null;
return this._doc.querySelector(`meta[${attrSelector}]`) || null;
const meta = this._doc.querySelector(`meta[${attrSelector}]`);
return meta?.nodeName.toLowerCase() === 'meta' ? meta : null;
}

/**
Expand All @@ -114,7 +115,11 @@ export class Meta {
getTags(attrSelector: string): HTMLMetaElement[] {
if (!attrSelector) return [];
const list /*NodeList*/ = this._doc.querySelectorAll(`meta[${attrSelector}]`);
return list ? [].slice.call(list) : [];
return list
? (([].slice.call(list) as HTMLElement[]).filter(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT:

Not sure why _doc in the constructor is any in this case, which does cause the need for castings.

Suggested change
? (([].slice.call(list) as HTMLElement[]).filter(
? ((Array.from(list) as HTMLElement[]).filter(

(elem) => elem.nodeName.toLowerCase() === 'meta',
) as HTMLMetaElement[])
: [];
}

/**
Expand Down Expand Up @@ -183,7 +188,13 @@ export class Meta {

private _parseSelector(tag: MetaDefinition): string {
const attr: string = tag.name ? 'name' : 'property';
return `${attr}="${tag[attr]}"`;
return `${attr}=${this._escapeSelectorValue(String(tag[attr]))}`;
}

private _escapeSelectorValue(value: string): string {
// Escape backslashes and double quotes to prevent CSS selector injection.
// This securely confines the value inside an attribute selector.
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}

private _containsAttributes(tag: MetaDefinition, elem: HTMLMetaElement): boolean {
Expand Down
55 changes: 55 additions & 0 deletions packages/platform-browser/test/browser/meta_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ describe('Meta service', () => {
expect(actual!.getAttribute('content')).toEqual('4321');
});

it('should not allow a custom selector to match off target elements like the body tag', () => {
// This payload attempts to break out of the `meta[name="..."]` constraint entirely
// and inject a comma to target arbitrary DOM elements like the `body` tag via the
// `selector` argument of `updateTag`.
const attackerSelector = 'name="description"], body, meta[name="pwned"';

const firstMeta = metaService.updateTag({content: 'pwned'}, attackerSelector)!;

expect(firstMeta).not.toBeNull();
// It creates a new meta element instead of targeting `body` because it did not
// find a meta element matching the dirty selector since `body` is not a `meta` tag
expect(firstMeta!.nodeName.toLowerCase()).toEqual('meta');
expect(firstMeta!.getAttribute('content')).toEqual('pwned');
expect(doc.body.getAttribute('content')).toBeNull();

metaService.removeTagElement(firstMeta);
});

it('should extract selector from the tag definition', () => {
const selector = 'property="fb:app_id"';
metaService.updateTag({property: 'fb:app_id', content: '666'});
Expand Down Expand Up @@ -137,6 +155,43 @@ describe('Meta service', () => {
metaService.removeTagElement(actual);
});

it('should escape selector values when deriving the match selector', () => {
// This payload attempts to prematurely close the attribute selector
// and match another attribute.
const property = 'fb:app_id"][content="123456789';

const meta = metaService.updateTag({property, content: 'pwned'})!;

expect(meta).not.toBe(defaultMeta);
expect(meta.getAttribute('property')).toEqual(property);
expect(meta.getAttribute('content')).toEqual('pwned');
expect(metaService.getTags('property="fb:app_id"').length).toEqual(1);

// clean up
metaService.removeTagElement(meta);
});

it('should not let a quoted name break out of the meta selector and target body', () => {
// This payload attempts to break out of the `meta[name="..."]` constraint entirely
// and inject a comma to target arbitrary DOM elements like the `body` tag.
const attackerName = 'description"], body';

const firstMeta = metaService.addTag({name: attackerName, content: 'safe'})!;
const secondMeta = metaService.addTag({name: attackerName, content: 'safe'})!;

expect(firstMeta).toBe(secondMeta);
expect(firstMeta.tagName).toEqual('META');
expect(
Array.from(doc.getElementsByTagName('meta')).filter(
(meta) => meta.getAttribute('name') === attackerName,
).length,
).toEqual(1);
expect(doc.body).not.toBeNull();

// clean up
metaService.removeTagElement(firstMeta);
});

it('should add multiple new meta tags', () => {
const nameSelector = 'name="twitter:title"';
const propertySelector = 'property="og:title"';
Expand Down
Loading