OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / clearnet-proxy.test.js

211 lines · javascript ref:
import test from 'node:test'
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import Module from 'node:module'
import nodeCrypto from 'node:crypto'
import { createRequire } from 'node:module'

const require = createRequire(import.meta.url)
const {
  encodeClearnetTarget,
  decodeClearnetTarget,
  localClearnetUrl,
  parseClearnetPath,
  rewriteHtmlForProxy,
  buildClearnetInjections,
  handleClearnetRequest
} = require('../backend/clearnet-proxy.cjs')
const { ContentShield } = require('../backend/content-shield.cjs')
const { SessionBridge } = require('../backend/session-bridge.cjs')

// hyper-proxy shim for chokepoint test
const origLoad = Module._load
Module._load = function (request, parent, isMain) {
  if (request === 'bare-crypto') return nodeCrypto
  if (request === 'bare-http1') return {}
  return origLoad.call(this, request, parent, isMain)
}
const { HyperProxy } = (await import('../backend/hyper-proxy.js')).default
Module._load = origLoad

test('encode/decode clearnet target is stable', () => {
  const url = 'https://example.com/path?q=1'
  const enc = encodeClearnetTarget(url)
  assert.equal(decodeClearnetTarget(enc), url)
  assert.equal(parseClearnetPath(`/clearnet/${enc}`).target, url)
  assert.match(localClearnetUrl(9876, url), /^http:\/\/127\.0\.0\.1:9876\/clearnet\//)
})

test('rewriteHtmlForProxy rewrites href/src to proxy paths', () => {
  const html = `<html><head></head><body>
    <a href="/next">n</a>
    <img src="https://cdn.example/a.png">
    <link rel="stylesheet" href="style.css">
  </body></html>`
  const out = rewriteHtmlForProxy(html, 'https://example.com/page', 'http://127.0.0.1:9')
  assert.match(out, /\/clearnet\//)
  assert.match(out, /<meta name="pear-clearnet-origin" content="https:\/\/example\.com">/)
  assert.doesNotMatch(out, /href="\/next"/)
  // Absolute CDN URL is base64url-encoded into the proxy path
  assert.match(out, /clearnet\/[A-Za-z0-9_-]+/)
  assert.ok(out.includes(encodeClearnetTarget('https://cdn.example/a.png')))
})

test('buildClearnetInjections adds shield CSS, scriptlets, farbling', () => {
  const shield = new ContentShield({ builtinList: false })
  shield.addList('t', '||ads.example^\n##.ad-banner\n##+js(set-constant, ads.on, false)')
  const { htmlFragment, scriptBodies } = buildClearnetInjections({
    contentShield: shield,
    documentUrl: 'https://news.example/',
    privacy: { fingerprintFarbling: true },
    farblingSalt: 'unit'
  })
  assert.match(htmlFragment, /data-pear-shield/)
  assert.match(htmlFragment, /\.ad-banner/)
  assert.match(htmlFragment, /data-pear-scriptlet/)
  assert.match(htmlFragment, /data-pear-farbling/)
  assert.ok(scriptBodies.length >= 2)
})

test('clearnet injection contains style-only plugin CSS inside its style element', () => {
  const payload = '</style><script>window.__clearnetBreakout=1</script><style>'
  const shield = new ContentShield({ builtinList: false })
  shield.applyPluginContribution('style-only', {
    styles: { matches: ['*'], css: payload }
  }, ['pear.content.styles'])

  const { htmlFragment } = buildClearnetInjections({
    contentShield: shield,
    documentUrl: 'https://news.example/',
    privacy: {}
  })
  const style = htmlFragment.match(/<style data-pear-plugin-style>([\s\S]*?)<\/style>/)
  assert.ok(style)
  assert.equal(style[1].includes('</style>'), false)
  assert.equal(htmlFragment.includes('<script>window.__clearnetBreakout=1</script>'), false)
  assert.match(style[1], /\\3c \/style>/)
})

test('handleClearnetRequest blocks before fetch when shield matches', async () => {
  const shield = new ContentShield({ builtinList: false })
  shield.addList('t', '||evil-ads.example^')
  let fetched = 0
  const req = new EventEmitter()
  req.method = 'GET'
  req.headers = {}
  const res = {
    statusCode: 200,
    headers: {},
    setHeader (k, v) { this.headers[k.toLowerCase()] = v },
    end (body) { this.body = String(body || ''); this.ended = true }
  }
  const target = 'https://evil-ads.example/pixel.gif'
  const path = `/clearnet/${encodeClearnetTarget(target)}`
  await handleClearnetRequest(req, res, new URL(path, 'http://127.0.0.1:9'), {
    contentShield: shield,
    privacy: {},
    proxyOrigin: 'http://127.0.0.1:9',
    fetchClearnet: async () => { fetched++; return { statusCode: 200, headers: {}, body: Buffer.from('x') } }
  })
  assert.equal(res.statusCode, 403)
  assert.equal(res.headers['x-pear-shield'], 'blocked')
  assert.equal(fetched, 0)
})

test('handleClearnetRequest rewrites HTML and injects shield on pass-through', async () => {
  const shield = new ContentShield({ builtinList: false })
  shield.addList('t', '##.ad')
  const req = new EventEmitter()
  req.method = 'GET'
  req.headers = { accept: 'text/html' }
  const res = {
    statusCode: 200,
    headers: {},
    _headers: {},
    setHeader (k, v) { this.headers[k.toLowerCase()] = v; this._headers[k.toLowerCase()] = v },
    getHeader (k) { return this._headers[k.toLowerCase()] },
    end (body) { this.body = Buffer.isBuffer(body) ? body.toString('utf8') : String(body || ''); this.ended = true }
  }
  const target = 'https://example.com/'
  await handleClearnetRequest(req, res, new URL(`/clearnet/${encodeClearnetTarget(target)}`, 'http://127.0.0.1:9'), {
    contentShield: shield,
    privacy: { fingerprintFarbling: true, blockThirdPartyCookies: true },
    proxyOrigin: 'http://127.0.0.1:9',
    fetchClearnet: async () => ({
      statusCode: 200,
      headers: { 'content-type': 'text/html; charset=utf-8', 'set-cookie': 'track=1' },
      body: Buffer.from('<html><head></head><body><a href="/x">x</a></body></html>')
    })
  })
  assert.equal(res.statusCode, 200)
  assert.equal(res.headers['x-pear-clearnet'], '1')
  assert.match(res.body, /data-pear-shield/)
  assert.match(res.body, /data-pear-farbling/)
  assert.match(res.body, /\/clearnet\//)
  assert.equal(res.headers['set-cookie'], undefined)
})

test('SessionBridge resolves clearnet to proxy localUrl by default', () => {
  const bridge = new SessionBridge({
    getShield: () => new ContentShield({ builtinList: false }),
    getPrivacy: () => ({ clearnetMode: 'proxy', httpsOnly: true, stripTrackingParams: true }),
    getProxyPort: () => 9876
  })
  const r = bridge.resolveNavigation('http://example.com/?utm_source=x')
  assert.equal(r.kind, 'clearnet')
  assert.equal(r.mode, 'proxy')
  assert.equal(r.upgraded, true)
  assert.ok(r.stripped.includes('utm_source'))
  assert.match(r.localUrl, /^http:\/\/127\.0\.0\.1:9876\/clearnet\//)
  assert.equal(r.shieldActive, true)
  assert.equal(r.url.startsWith('https://'), true)
})

test('SessionBridge direct mode returns real https URL', () => {
  const bridge = new SessionBridge({
    getPrivacy: () => ({ clearnetMode: 'direct' }),
    getProxyPort: () => 9876
  })
  const r = bridge.resolveNavigation('https://example.com/')
  assert.equal(r.mode, 'direct')
  assert.equal(r.localUrl, 'https://example.com/')
  assert.equal(r.shieldActive, false)
})

test('SessionBridge.shouldBlockRequest uses ContentShield', () => {
  const shield = new ContentShield({ builtinList: false })
  shield.addList('t', '||tracker.example^')
  const bridge = new SessionBridge({ getShield: () => shield })
  assert.equal(bridge.shouldBlockRequest({ url: 'https://tracker.example/x' }).cancel, true)
  assert.equal(bridge.shouldBlockRequest({ url: 'https://ok.example/' }).cancel, false)
})

test('HyperProxy routes /clearnet/* to clearnet handler', async () => {
  const proxy = new HyperProxy(async () => null, () => {})
  proxy._port = 9876
  let seen = null
  proxy.setClearnetHandler(async (req, res, urlObj, deps) => {
    seen = { path: urlObj.pathname, hasShield: !!deps.contentShield }
    res.statusCode = 204
    res.end()
    return true
  })
  proxy.setContentShield(new ContentShield({ builtinList: false }))
  const req = new EventEmitter()
  req.method = 'GET'
  req.url = `/clearnet/${encodeClearnetTarget('https://example.com/')}`
  req.headers = {}
  req.socket = { remoteAddress: '127.0.0.1' }
  const res = {
    statusCode: 200,
    headers: {},
    setHeader (k, v) { this.headers[k.toLowerCase()] = v },
    end () { this.ended = true }
  }
  await proxy._handle(req, res)
  assert.ok(seen)
  assert.match(seen.path, /^\/clearnet\//)
  assert.equal(seen.hasShield, true)
  assert.equal(res.statusCode, 204)
})