OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 6592f70

Escape plugin CSS in clearnet injection
6592f700a249ba775ee8d028d8235ef5ee0377d7
by Local LLM <localllm@local> · 2026-07-16T10:30:51.000Z

Changed files (4) +34 -11

backend/clearnet-proxy.cjs +3 -2
……@@ -21,6 +21,7 @@ const {
2121 referrerPolicyMeta,
2222 normalizePrivacySettings
2323} = require('./privacy-policy.cjs')
24const { escapeStyleText } = require('./html-raw-text.cjs')
2425
2526const MAX_BODY_BYTES = 8 * 1024 * 1024 // 8 MiB response cap for proxy mode
2627const FETCH_TIMEOUT_MS = 20000
……@@ -243,7 +244,7 @@ function buildClearnetInjections (opts = {}) {
243244 const css = contentShield.cosmeticCssFor
244245 ? contentShield.cosmeticCssFor(host, {})
245246 : ''
246 if (css) fragments.push(`<style data-pear-shield>${css}</style>`)
247 if (css) fragments.push(`<style data-pear-shield>${escapeStyleText(css)}</style>`)
247248
248249 if (typeof contentShield.scriptletsFor === 'function') {
249250 for (const entry of contentShield.scriptletsFor(host, {})) {
……@@ -255,7 +256,7 @@ function buildClearnetInjections (opts = {}) {
255256 }
256257 if (typeof contentShield.pluginStylesFor === 'function') {
257258 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>`)
259260 }
260261 if (typeof contentShield.pluginScriptsFor === 'function') {
261262 for (const entry of contentShield.pluginScriptsFor(host, {})) {
backend/html-raw-text.cjs +11 -0
……@@ -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.
6function escapeStyleText (css) {
7 if (typeof css !== 'string') return ''
8 return css.replace(/</g, '\\3c ')
9}
10
11module.exports = { escapeStyleText }
backend/hyper-proxy.js +1 -9
……@@ -11,6 +11,7 @@
1111const http = require('bare-http1')
1212const crypto = require('bare-crypto')
1313const { PAGE_CONTEXT_SHIM, PAGE_CONTEXT_SHIM_HASH, pageContextMeta } = require('./page-context-bridge.cjs')
14const { escapeStyleText } = require('./html-raw-text.cjs')
1415
1516const USER_FRIENDLY_ERRORS = {
1617 'Invalid drive key': 'This link appears to be broken or incomplete',
……@@ -201,15 +202,6 @@ function escapeHtml (str) {
201202 .replace(/`/g, '&#96;')
202203}
203204
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.
208function escapeStyleText (css) {
209 if (typeof css !== 'string') return ''
210 return css.replace(/</g, '\\3c ')
211}
212
213205class HyperProxy {
214206 constructor (getDrive, onError, relayClient, opts = {}) {
215207 this._getDrive = getDrive // async (keyHex) => Hyperdrive
test/clearnet-proxy.test.js +19 -0
……@@ -67,6 +67,25 @@ test('buildClearnetInjections adds shield CSS, scriptlets, farbling', () => {
6767 assert.ok(scriptBodies.length >= 2)
6868})
6969
70test('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
7089test('handleClearnetRequest blocks before fetch when shield matches', async () => {
7190 const shield = new ContentShield({ builtinList: false })
7291 shield.addList('t', '||evil-ads.example^')