| … | … | @@ -21,6 +21,7 @@ const { |
| 21 | 21 | referrerPolicyMeta, |
| 22 | 22 | normalizePrivacySettings |
| 23 | 23 | } = require('./privacy-policy.cjs') |
| 24 | const { escapeStyleText } = require('./html-raw-text.cjs') | |
| 24 | 25 | |
| 25 | 26 | const MAX_BODY_BYTES = 8 * 1024 * 1024 // 8 MiB response cap for proxy mode |
| 26 | 27 | const FETCH_TIMEOUT_MS = 20000 |
| … | … | @@ -243,7 +244,7 @@ function buildClearnetInjections (opts = {}) { |
| 243 | 244 | const css = contentShield.cosmeticCssFor |
| 244 | 245 | ? contentShield.cosmeticCssFor(host, {}) |
| 245 | 246 | : '' |
| 246 | if (css) fragments.push(`<style data-pear-shield>${css}</style>`) | |
| 247 | if (css) fragments.push(`<style data-pear-shield>${escapeStyleText(css)}</style>`) | |
| 247 | 248 | |
| 248 | 249 | if (typeof contentShield.scriptletsFor === 'function') { |
| 249 | 250 | for (const entry of contentShield.scriptletsFor(host, {})) { |
| … | … | @@ -255,7 +256,7 @@ function buildClearnetInjections (opts = {}) { |
| 255 | 256 | } |
| 256 | 257 | if (typeof contentShield.pluginStylesFor === 'function') { |
| 257 | 258 | const pcss = contentShield.pluginStylesFor(host, {}) |
| 258 | if (pcss) fragments.push(`<style data-pear-plugin-style>${pcss}</style>`) | |
| 259 | if (pcss) fragments.push(`<style data-pear-plugin-style>${escapeStyleText(pcss)}</style>`) | |
| 259 | 260 | } |
| 260 | 261 | if (typeof contentShield.pluginScriptsFor === 'function') { |
| 261 | 262 | for (const entry of contentShield.pluginScriptsFor(host, {})) { |
| … | … | @@ -0,0 +1,11 @@ |
| 1 | 'use strict' | |
| 2 | ||
| 3 | // HTML treats <style> contents as raw text and closes the element on a literal | |
| 4 | // "</style", regardless of CSS tokenization. Encode every less-than sign as a | |
| 5 | // CSS code point before embedding third-party CSS at any HTML injection sink. | |
| 6 | function escapeStyleText (css) { | |
| 7 | if (typeof css !== 'string') return '' | |
| 8 | return css.replace(/</g, '\\3c ') | |
| 9 | } | |
| 10 | ||
| 11 | module.exports = { escapeStyleText } |
| … | … | @@ -11,6 +11,7 @@ |
| 11 | 11 | const http = require('bare-http1') |
| 12 | 12 | const crypto = require('bare-crypto') |
| 13 | 13 | const { PAGE_CONTEXT_SHIM, PAGE_CONTEXT_SHIM_HASH, pageContextMeta } = require('./page-context-bridge.cjs') |
| 14 | const { escapeStyleText } = require('./html-raw-text.cjs') | |
| 14 | 15 | |
| 15 | 16 | const USER_FRIENDLY_ERRORS = { |
| 16 | 17 | 'Invalid drive key': 'This link appears to be broken or incomplete', |
| … | … | @@ -201,15 +202,6 @@ function escapeHtml (str) { |
| 201 | 202 | .replace(/`/g, '`') |
| 202 | 203 | } |
| 203 | 204 | |
| 204 | // A <style> element is an HTML raw-text container: the HTML parser will end it | |
| 205 | // on a literal "</style" even when those bytes came from otherwise-valid CSS. | |
| 206 | // Escape every less-than sign as a CSS code point before embedding third-party | |
| 207 | // filter/plugin CSS so a style-only capability can never become script markup. | |
| 208 | function escapeStyleText (css) { | |
| 209 | if (typeof css !== 'string') return '' | |
| 210 | return css.replace(/</g, '\\3c ') | |
| 211 | } | |
| 212 | ||
| 213 | 205 | class HyperProxy { |
| 214 | 206 | constructor (getDrive, onError, relayClient, opts = {}) { |
| 215 | 207 | this._getDrive = getDrive // async (keyHex) => Hyperdrive |
| … | … | @@ -67,6 +67,25 @@ test('buildClearnetInjections adds shield CSS, scriptlets, farbling', () => { |
| 67 | 67 | assert.ok(scriptBodies.length >= 2) |
| 68 | 68 | }) |
| 69 | 69 | |
| 70 | test('clearnet injection contains style-only plugin CSS inside its style element', () => { | |
| 71 | const payload = '</style><script>window.__clearnetBreakout=1</script><style>' | |
| 72 | const shield = new ContentShield({ builtinList: false }) | |
| 73 | shield.applyPluginContribution('style-only', { | |
| 74 | styles: { matches: ['*'], css: payload } | |
| 75 | }, ['pear.content.styles']) | |
| 76 | ||
| 77 | const { htmlFragment } = buildClearnetInjections({ | |
| 78 | contentShield: shield, | |
| 79 | documentUrl: 'https://news.example/', | |
| 80 | privacy: {} | |
| 81 | }) | |
| 82 | const style = htmlFragment.match(/<style data-pear-plugin-style>([\s\S]*?)<\/style>/) | |
| 83 | assert.ok(style) | |
| 84 | assert.equal(style[1].includes('</style>'), false) | |
| 85 | assert.equal(htmlFragment.includes('<script>window.__clearnetBreakout=1</script>'), false) | |
| 86 | assert.match(style[1], /\\3c \/style>/) | |
| 87 | }) | |
| 88 | ||
| 70 | 89 | test('handleClearnetRequest blocks before fetch when shield matches', async () => { |
| 71 | 90 | const shield = new ContentShield({ builtinList: false }) |
| 72 | 91 | shield.addList('t', '||evil-ads.example^') |