Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

REPLAY-1075: Resolve relative URLS to absolute in stylesheets #1792

Merged
Merged
Show file tree
Hide file tree
Changes from 3 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
94 changes: 93 additions & 1 deletion packages/rum/src/domain/record/serializationUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { isIE } from '@datadog/browser-core'
import { NodePrivacyLevel } from '../../constants'
import { getSerializedNodeId, hasSerializedNode, setSerializedNodeId, getElementInputValue } from './serializationUtils'
import {
getSerializedNodeId,
hasSerializedNode,
setSerializedNodeId,
getElementInputValue,
switchToAbsoluteUrl,
} from './serializationUtils'

describe('serialized Node storage in DOM Nodes', () => {
describe('hasSerializedNode', () => {
Expand Down Expand Up @@ -95,3 +101,89 @@ describe('getElementInputValue', () => {
})
})
})

describe('Build stylesheet absolute url', () => {
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved
const cssHref = 'https://site.web/app-name/static/assets/resource.min.css'
const resolvedPath = 'https://site.web/app-name/static/assets/fonts/fontawesome-webfont.eot'

describe('convert relative path to absolute', () => {
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved
it('should replace url when wrapped in single quote', () => {
const cssText = "{ font-family: FontAwesome; src: url('./fonts/fontawesome-webfont.eot'); }"
expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(
`{ font-family: FontAwesome; src: url('${resolvedPath}'); }`
)
})
it('should replace url when wrapped in double quote', () => {
const cssText = '{ font-family: FontAwesome; src: url("./fonts/fontawesome-webfont.eot"); }'
expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(
`{ font-family: FontAwesome; src: url("${resolvedPath}"); }`
)
})
it('should replace url when not in any quote', () => {
const cssText = '{ font-family: FontAwesome; src: url(./fonts/fontawesome-webfont.eot); }'

expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(`{ font-family: FontAwesome; src: url(${resolvedPath}); }`)
})
it('should replace url when path is relative', () => {
const cssText = '{ font-family: FontAwesome; src: url(fonts/fontawesome-webfont.eot); }'

expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(`{ font-family: FontAwesome; src: url(${resolvedPath}); }`)
})
it('should replace url when path is at parent level', () => {
const cssText = "{ font-family: FontAwesome; src: url('../fonts/fontawesome-webfont.eot'); }"

expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(
"{ font-family: FontAwesome; src: url('https://site.web/app-name/static/fonts/fontawesome-webfont.eot'); }"
)
bcaudan marked this conversation as resolved.
Show resolved Hide resolved
})
it('should replace multiple urls at the same time', () => {
const cssText =
'{ background-image: url(../images/pic.png); src: url("fonts/fantasticfont.woff"); content: url("./icons/icon.jpg");}'
expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(
'{ background-image: url(https://site.web/app-name/static/images/pic.png); src: url("https://site.web/app-name/static/assets/fonts/fantasticfont.woff"); content: url("https://site.web/app-name/static/assets/icons/icon.jpg");}'
)
})
})

describe('keep urls in css text unchanged', () => {
it('should not replace url if baseUrl is null', () => {
const cssText = '{ font-family: FontAwesome; src: url(./fonts/fontawesome-webfont.eot); }'

expect(switchToAbsoluteUrl(cssText, null)).toEqual(cssText)
})
it('should not replace url if it is empty', () => {
const cssText = '{ font-family: FontAwesome; src: url(); }'

expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(cssText)
})
it('should not replace url if already absolute', () => {
const cssText =
'{ font-family: FontAwesome; src: url(https://site.web/app-name/static/assets/fonts/fontawesome-webfont.eot); }'

expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(cssText)
})
it('should not replace url if it starts with //', () => {
const cssText =
'{ font-family: FontAwesome; src: url(//site.web/app-name/static/assets/fonts/fontawesome-webfont.eot); }'

expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(cssText)
})
it('should not replace url if data uri: lower case', () => {
const cssText =
'{ font-family: FontAwesome; src: url(data:image/png;base64,iVBORNSUhEUgAAVR42mP8z/C/HgwJ/lK3Q6wAkJggg==); }'

expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(cssText)
})
it('should not replace url if data uri: not lower case', () => {
const cssText =
'{ font-family: FontAwesome; src: url(DaTa:image/png;base64,iVBORNSUhEUgAAVR42mP8z/C/HgwJ/lK3Q6wAkJggg==); }'

expect(switchToAbsoluteUrl(cssText, cssHref)).toEqual(cssText)
})
it('should not replace url if error is thrown when building absolute url', () => {
const cssText =
'{ font-family: FontAwesome; src: url(https://site.web/app-name/static/assets/fonts/fontawesome-webfont.eot); }'
expect(switchToAbsoluteUrl(cssText, 'hello-world')).toEqual(cssText)
})
})
})
36 changes: 36 additions & 0 deletions packages/rum/src/domain/record/serializationUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { buildUrl } from '@datadog/browser-core'
import type { NodePrivacyLevel } from '../../constants'
import { CENSORED_STRING_MARK } from '../../constants'
import { shouldMaskNode } from './privacy'
Expand Down Expand Up @@ -69,3 +70,38 @@ export function getElementInputValue(element: Element, nodePrivacyLevel: NodePri

return value
}

export const URL_IN_CSS_REF = /url\((?:(')([^']*)'|(")([^"]*)"|([^)]*))\)/gm
export const ABSOLUTE_URL = /^[A-Za-z]+:|^\/\//
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved
export const DATA_URI = /^data:.*,/i
N-Boutaib marked this conversation as resolved.
Show resolved Hide resolved

export function switchToAbsoluteUrl(cssText: string, cssHref: string | null): string {
return cssText.replace(
URL_IN_CSS_REF,
(
matchingSubstring: string,
singleQuote: string | undefined,
urlWrappedInSingleQuotes: string | undefined,
doubleQuote: string | undefined,
urlWrappedInDoubleQuotes: string | undefined,
urlNotWrappedInQuotes: string | undefined
) => {
const url = urlWrappedInSingleQuotes || urlWrappedInDoubleQuotes || urlNotWrappedInQuotes

if (!cssHref || !url || ABSOLUTE_URL.test(url) || DATA_URI.test(url)) {
return matchingSubstring
}

const quote = singleQuote || doubleQuote || ''
return `url(${quote}${makeUrlAbsolute(url, cssHref)}${quote})`
}
)
}

export function makeUrlAbsolute(url: string, baseUrl: string): string {
try {
return buildUrl(url, baseUrl).href
} catch (_) {
return url
}
}
14 changes: 12 additions & 2 deletions packages/rum/src/domain/record/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import {
getNodeSelfPrivacyLevel,
MAX_ATTRIBUTE_VALUE_CHAR_LENGTH,
} from './privacy'
import { getSerializedNodeId, setSerializedNodeId, getElementInputValue } from './serializationUtils'
import {
getSerializedNodeId,
setSerializedNodeId,
getElementInputValue,
switchToAbsoluteUrl,
} from './serializationUtils'
import { forEach } from './utils'
import type { ElementsScrollPositions } from './elementsScrollPositions'

Expand Down Expand Up @@ -316,7 +321,12 @@ function getValidTagName(tagName: string): string {
function getCssRulesString(s: CSSStyleSheet): string | null {
try {
const rules = s.rules || s.cssRules
return rules ? Array.from(rules).map(getCssRuleString).join('') : null
if (rules) {
const styleSheetCssText = Array.from(rules, getCssRuleString).join('')
return switchToAbsoluteUrl(styleSheetCssText, s.href)
}

return null
} catch (error) {
return null
}
Expand Down