Skip to content

Commit 8e60b42

Browse files
authored
Merge pull request #332 from ezzak/css_defense
Capture dubious CSS
2 parents 0ee0880 + 8f6e7e7 commit 8e60b42

File tree

2 files changed

+121
-0
lines changed

2 files changed

+121
-0
lines changed

src/js/content/scanForCSS.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {STATES} from '../config';
9+
import {updateCurrentState} from './updateCurrentState';
10+
11+
const CHECKED_STYLESHEET_HREFS = new Set<string>();
12+
const CHECKED_STYLESHEET_HASHES = new Set<string>();
13+
14+
export default function scanForCSS(): void {
15+
checkForStylesheetChanges();
16+
17+
setInterval(checkForStylesheetChanges, 1000);
18+
}
19+
20+
async function checkForStylesheetChanges() {
21+
[...document.styleSheets].forEach(async sheet => {
22+
const isValid = await checkIsStylesheetValid(sheet);
23+
updateStateOnInvalidStylesheet(isValid, sheet);
24+
});
25+
}
26+
27+
async function checkIsStylesheetValid(
28+
styleSheet: CSSStyleSheet,
29+
): Promise<boolean> {
30+
const potentialOwnerNode = styleSheet.ownerNode;
31+
if (
32+
// CSS external resource
33+
styleSheet.href &&
34+
potentialOwnerNode instanceof Element &&
35+
potentialOwnerNode.tagName === 'LINK'
36+
) {
37+
if (CHECKED_STYLESHEET_HREFS.has(styleSheet.href)) {
38+
return true;
39+
}
40+
CHECKED_STYLESHEET_HREFS.add(styleSheet.href);
41+
ensureCORSEnabledForStylesheet(styleSheet);
42+
} else if (
43+
// Inline css
44+
potentialOwnerNode instanceof Element &&
45+
potentialOwnerNode.tagName === 'STYLE'
46+
) {
47+
const hashedContent = await hashString(
48+
potentialOwnerNode.textContent ?? '',
49+
);
50+
if (CHECKED_STYLESHEET_HASHES.has(hashedContent)) {
51+
return true;
52+
}
53+
CHECKED_STYLESHEET_HASHES.add(hashedContent);
54+
}
55+
return [...styleSheet.cssRules].every(isValidCSSRule);
56+
}
57+
58+
function isValidCSSRule(rule: CSSRule): boolean {
59+
if (
60+
rule instanceof CSSKeyframeRule &&
61+
rule.style.getPropertyValue('font-family') !== ''
62+
) {
63+
// Attempting to animate fonts
64+
return false;
65+
}
66+
67+
if (
68+
!(
69+
rule instanceof CSSGroupingRule ||
70+
rule instanceof CSSKeyframesRule ||
71+
rule instanceof CSSImportRule
72+
)
73+
) {
74+
return true;
75+
}
76+
77+
let rulesToCheck: Array<CSSRule> = [];
78+
79+
if (rule instanceof CSSImportRule) {
80+
const styleSheet = rule.styleSheet;
81+
if (styleSheet != null) {
82+
ensureCORSEnabledForStylesheet(styleSheet);
83+
rulesToCheck = [...styleSheet.cssRules];
84+
}
85+
} else {
86+
rulesToCheck = [...rule.cssRules];
87+
}
88+
89+
return rulesToCheck.every(isValidCSSRule);
90+
}
91+
92+
function ensureCORSEnabledForStylesheet(styleSheet: CSSStyleSheet): void {
93+
try {
94+
// Ensure all non same origin stylesheets can be accessed (CORS)
95+
styleSheet.cssRules;
96+
} catch (e) {
97+
updateStateOnInvalidStylesheet(false, styleSheet);
98+
}
99+
}
100+
101+
async function hashString(content: string): Promise<string> {
102+
const text = new TextEncoder().encode(content);
103+
const hashBuffer = await crypto.subtle.digest('SHA-256', text);
104+
const hashArray = Array.from(new Uint8Array(hashBuffer));
105+
return hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
106+
}
107+
108+
function updateStateOnInvalidStylesheet(
109+
isValid: boolean,
110+
sheet: CSSStyleSheet,
111+
): void {
112+
if (!isValid) {
113+
const potentialHref = sheet.href ?? '';
114+
updateCurrentState(
115+
STATES.INVALID,
116+
`Violating CSS stylesheet ${potentialHref}`,
117+
);
118+
}
119+
}

src/js/contentUtils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {checkWorkerEndpointCSP} from './content/checkWorkerEndpointCSP';
3636
import {MessagePayload} from './shared/MessageTypes';
3737
import {pushToOrCreateArrayInMap} from './shared/nestedDataHelpers';
3838
import ensureManifestWasOrWillBeLoaded from './content/ensureManifestWasOrWillBeLoaded';
39+
import scanForCSS from './content/scanForCSS';
3940

4041
type ContentScriptConfig = {
4142
checkLoggedInFromCookie: boolean;
@@ -544,6 +545,7 @@ export function startFor(origin: Origin, config: ContentScriptConfig): void {
544545
if (isUserLoggedIn) {
545546
updateCurrentState(STATES.PROCESSING);
546547
scanForScripts();
548+
scanForCSS();
547549
// set the timeout once, in case there's an iframe and contentUtils sets another manifest timer
548550
if (manifestTimeoutID === '') {
549551
manifestTimeoutID = window.setTimeout(() => {

0 commit comments

Comments
 (0)