Skip to content

Commit 3dd35c2

Browse files
arturovtatscott
authored andcommitted
fix(common): escape CSS string-terminating characters in escapeCssUrl
The `escapeCssUrl` helper used by `NgOptimizedImage` to sanitize placeholder URLs for use in the `background-image` CSS property previously escaped only backslashes and double quotes. However, several characters that can terminate a CSS quoted string according to the CSS Syntax Level 3 specification were left unescaped, allowing a crafted placeholder URL to break out of the `url("...")` context and inject arbitrary CSS. This change additionally escapes the following characters using CSS hex escapes: * `U+000A` (LINE FEED) → `\A ` * `U+000D` (CARRIAGE RETURN) → `\D ` * `U+000C` (FORM FEED) → `\C ` * `U+0000` (NULL) → `\0 ` For example: ```text id="1w5vkp" x.com/img\nx.jpg → x.com/img\A x.jpg x.com/img\rx.jpg → x.com/img\D x.jpg x.com/img\fx.jpg → x.com/img\C x.jpg x.com/img\0x.jpg → x.com/img\0 x.jpg ``` The trailing space is required by the CSS tokenizer to terminate the escape sequence and prevent the following character from being interpreted as part of the escape. The backslash replacement remains first in the chain to avoid double-escaping the backslashes introduced by subsequent replacements.
1 parent c121407 commit 3dd35c2

3 files changed

Lines changed: 40 additions & 4 deletions

File tree

packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {netlifyLoaderInfo} from './image_loaders/netlify_loader';
4848
import {LCPImageObserver} from './lcp_image_observer';
4949
import {PreconnectLinkChecker} from './preconnect_link_checker';
5050
import {PreloadLinkCreator} from './preload-link-creator';
51+
import {escapeCssUrl} from './url';
5152

5253
/**
5354
* When a Base64-encoded image is passed as an input to the `NgOptimizedImage` directive,
@@ -1463,10 +1464,6 @@ function unwrapSafeUrl(value: string | SafeValue): string {
14631464
return unwrapSafeValue(value);
14641465
}
14651466

1466-
function escapeCssUrl(input: string): string {
1467-
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1468-
}
1469-
14701467
// Transform function to handle inputs which may be booleans, strings, or string representations
14711468
// of boolean values. Used for the placeholder attribute.
14721469
export function booleanOrUrlAttribute(value: boolean | string): boolean | string {

packages/common/src/directives/ng_optimized_image/url.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,17 @@ export function normalizePath(path: string): string {
4646
export function normalizeSrc(src: string): string {
4747
return src.startsWith('/') ? src.slice(1) : src;
4848
}
49+
50+
export function escapeCssUrl(input: string): string {
51+
return (
52+
input
53+
// Backslash first — later replacements must not have their own \ escaped again.
54+
.replace(/\\/g, '\\\\')
55+
// \n, \r, \f and null terminate a CSS string (CSS Syntax 3 §4.3.5,
56+
// https://www.w3.org/TR/css-syntax-3/#consume-string-token) and are also
57+
// invalid in URLs, so stripping them is safe.
58+
.replace(/[\n\r\f\0]/g, '')
59+
// A bare " would close the url("...") wrapper early.
60+
.replace(/"/g, '\\"')
61+
);
62+
}

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
resetImagePriorityCount,
3333
} from '../../src/directives/ng_optimized_image/ng_optimized_image';
3434
import {PRECONNECT_CHECK_BLOCKLIST} from '../../src/directives/ng_optimized_image/preconnect_link_checker';
35+
import {escapeCssUrl} from '../../src/directives/ng_optimized_image/url';
3536

3637
describe('Image directive', () => {
3738
const PLACEHOLDER_BLUR_AMOUNT = 15;
@@ -2616,6 +2617,30 @@ describe('Image directive', () => {
26162617
});
26172618
});
26182619

2620+
describe('escapeCssUrl', () => {
2621+
it('strips characters that are invalid in URLs and break CSS quoted strings', () => {
2622+
// \n, \r, \f and \0 all terminate a CSS string per the spec, and none of
2623+
// them are valid URL characters either, so we just drop them.
2624+
expect(escapeCssUrl('http://x.com/img\n.jpg')).toBe('http://x.com/img.jpg'); // newline
2625+
expect(escapeCssUrl('http://x.com/img\r.jpg')).toBe('http://x.com/img.jpg'); // carriage return
2626+
expect(escapeCssUrl('http://x.com/img\f.jpg')).toBe('http://x.com/img.jpg'); // form feed
2627+
expect(escapeCssUrl('http://x.com/img\0.jpg')).toBe('http://x.com/img.jpg'); // null byte
2628+
});
2629+
2630+
it('escapes characters that break the CSS url("...") wrapper', () => {
2631+
// A bare " would end the url("...") string prematurely, so it becomes \".
2632+
expect(escapeCssUrl('http://x.com/img".jpg')).toBe('http://x.com/img\\".jpg');
2633+
// A bare \ starts a CSS escape sequence, so it must be doubled to \\.
2634+
expect(escapeCssUrl('http://x.com/img\\.jpg')).toBe('http://x.com/img\\\\.jpg');
2635+
});
2636+
2637+
it('does not double-escape backslashes', () => {
2638+
// The \ → \\ replacement runs first, so backslashes introduced by later
2639+
// replacements are never accidentally escaped a second time.
2640+
expect(escapeCssUrl('a\\b')).toBe('a\\\\b');
2641+
});
2642+
});
2643+
26192644
// Helpers
26202645

26212646
// Base URL that can be used in tests to construct absolute URLs.

0 commit comments

Comments
 (0)