Skip to content

Commit

Permalink
feat(mv3): ✨ Adding rule-recon logic
Browse files Browse the repository at this point in the history
  • Loading branch information
whizzzkid committed Apr 4, 2023
1 parent 9bdd4f3 commit 2d2124c
Showing 1 changed file with 155 additions and 45 deletions.
200 changes: 155 additions & 45 deletions add-on/src/lib/redirect-handler/blockOrObserve.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,58 @@
import browser from 'webextension-polyfill'
import debug from 'debug'
import { CompanionState } from '../../types/companion.js'
import { dropSlash } from '../ipfs-path.js'

const log = debug('ipfs-companion:redirect-handler:blockOrObserve')
log.error = debug('ipfs-companion:redirect-handler:blockOrObserve:error')

const savedRegexFilters = new Map<string, string>()
interface regexFilterMap {
id: number
regexSubstitution: string
}

interface redirectHandlerInput {
originUrl: string
redirectUrl: string
}

const savedRegexFilters: Record<string, regexFilterMap> = {}
const DEFAULT_LOCAL_RULES: redirectHandlerInput[] = [
{
originUrl: 'http://127.0.0.1',
redirectUrl: 'http://localhost'
},
{
originUrl: 'http://[::1]',
redirectUrl: 'http://localhost'
}
]


/**
*
* @param url
* @returns
*/
export function isLocalHost(url: string): boolean {
return url.startsWith('http://127.0.0.1') ||
url.startsWith('http://localhost') ||
url.startsWith('http://[::1]')
}

/**
* Escape the characters that are allowed in the URL, but not in the regex.
*
* @param str URL string to escape
* @returns
*/
function escapeURLRegex (str: string): string {
// these characters are allowed in the URL, but not in the regex.
// eslint-disable-next-line no-useless-escape
const ALLOWED_CHARS_URL_REGEX = /([:\/\?#\[\]@!$&'\(\ )\*\+,;=-_\.~])/g
return str.replace(ALLOWED_CHARS_URL_REGEX, '\\$1')
}

/**
* Construct a regex filter and substitution for a redirect.
*
Expand All @@ -23,9 +64,6 @@ function constructRegexFilter ({ originUrl, redirectUrl }: redirectHandlerInput)
regexSubstitution: string
regexFilter: string
} {
// these characters are allowed in the URL, but not in the regex.
// eslint-disable-next-line no-useless-escape
const ALLOWED_CHARS_URL_REGEX = /([:\/\?#\[\]@!$&'\(\ )\*\+,;=-_\.~])/g
// We can traverse the URL from the end, and find the first character that is different.
let commonIdx = 1
while (commonIdx < Math.min(originUrl.length, redirectUrl.length)) {
Expand All @@ -36,11 +74,21 @@ function constructRegexFilter ({ originUrl, redirectUrl }: redirectHandlerInput)
}

// We can now construct the regex filter and substitution.
const regexSubstitution = redirectUrl.slice(0, redirectUrl.length - commonIdx + 1) + '\\1'
let regexSubstitution = redirectUrl.slice(0, redirectUrl.length - commonIdx + 1) + '\\1'
// We need to escape the characters that are allowed in the URL, but not in the regex.
const regexFilterFirst = `${originUrl.slice(0, originUrl.length - commonIdx + 1).replace(ALLOWED_CHARS_URL_REGEX, '\\$1')}`
const regexFilterFirst = escapeURLRegex(originUrl.slice(0, originUrl.length - commonIdx + 1))
// We need to match the rest of the URL, so we can use a wildcard.
const regexFilter = `^${regexFilterFirst}(.*)$`
let regexFilter = `^${regexFilterFirst}(.*)$`.replace('https', 'https?')

// This method does not parse:
// originUrl: "https://awesome.ipfs.io/"
// redirectUrl: "http://localhost:8081/ipns/awesome.ipfs.io/"
// that ends up with capturing all urls which we do not want.
if (regexFilter === '^https?\\:\\/(.*)$') {
const subdomain = new URL(originUrl).hostname
regexFilter = `^https?\\:\\/\\/${escapeURLRegex(subdomain)}(.*)$`
regexSubstitution = regexSubstitution.replace('\\1', `/${subdomain}\\1`)
}

return { regexSubstitution, regexFilter }
}
Expand All @@ -58,6 +106,89 @@ export function getExtraInfoSpec<T> (additionalParams: T[] = []): T[] {
return additionalParams
}

async function reconcileRulesAndRemoveOld(state: CompanionState): Promise<void> {
const rules = await browser.declarativeNetRequest.getDynamicRules()
let addRules: browser.DeclarativeNetRequest.Rule[] = []
let removeRuleIds: number[] = []
for (const rule of rules) {
if (rule.action.type === 'redirect') {
if (!rule.action.redirect?.regexSubstitution?.includes(dropSlash(state.gwURLString)) ||
savedRegexFilters[rule.condition.regexFilter as string]?.regexSubstitution !== rule.action.redirect?.regexSubstitution ||
savedRegexFilters[rule.condition.regexFilter as string]?.id !== rule.id
) {
// We need to remove the old rule.
removeRuleIds.push(rule.id)
delete savedRegexFilters[rule.condition.regexFilter as string]
} else {
savedRegexFilters[rule.condition.regexFilter as string] = {
id: rule.id,
regexSubstitution: rule.action.redirect?.regexSubstitution as string
}
}
}
}
for (const { originUrl, redirectUrl } of DEFAULT_LOCAL_RULES) {
const { port } = new URL(state.gwURLString)
const regexFilter = `^${escapeURLRegex(`${originUrl}:${port}`)}(.*)$`
const regexSubstitution = `${redirectUrl}:${port}\\1`

if (!(regexFilter in savedRegexFilters)) {
// We need to add the new rule.
addRules.push(generateRule(regexFilter, regexSubstitution))
}
}
await browser.declarativeNetRequest.updateDynamicRules({ addRules, removeRuleIds })
}

/**
* Generates a rule for the declarativeNetRequest API.
*
* @param regexFilter - The regex filter for the rule.
* @param regexSubstitution - The regex substitution for the rule.
* @param excludedInitiatorDomains - The domains that are excluded from the rule.
* @returns
*/
function generateRule(
regexFilter: string,
regexSubstitution: string,
excludedInitiatorDomains: string[] = []
): browser.DeclarativeNetRequest.Rule {
// We need to generate a random ID for the rule.
const id = Math.floor(Math.random() * 29999)
// We need to save the regex filter and ID to check if the rule already exists later.
savedRegexFilters[regexFilter] = { id, regexSubstitution }

return {
id,
priority: 1,
action: {
type: 'redirect',
redirect: { regexSubstitution }
},
condition: {
regexFilter,
excludedInitiatorDomains,
resourceTypes: [
'csp_report',
'font',
'image',
'main_frame',
'media',
'object',
'other',
'ping',
'script',
'stylesheet',
'sub_frame',
'webbundle',
'websocket',
'webtransport',
'xmlhttprequest'
]
}
}
}

/**
* Register a redirect rule in the dynamic rule set.
*
Expand All @@ -71,56 +202,35 @@ export function addRuleToDynamicRuleSetGenerator (
const state = getState()
// We don't want to redirect to the same URL. Or to the gateway.
if (originUrl === redirectUrl ||
(originUrl.includes(state.gwURL.host) && !redirectUrl.includes('recovery'))) {
(originUrl.includes(state.gwURL.host) && !redirectUrl.includes('recovery') ||
(isLocalHost(redirectUrl) && isLocalHost(originUrl)))) {
return
}

// We need to generate a random ID for the rule.
const id = Math.floor(Math.random() * 29999)
// We need to construct the regex filter and substitution.
const { regexSubstitution, regexFilter } = constructRegexFilter({ originUrl, redirectUrl })

// We need to check if the rule already exists.
if (!savedRegexFilters.has(regexFilter)) {
if (!(regexFilter in savedRegexFilters) ||
savedRegexFilters[regexFilter].regexSubstitution !== regexSubstitution) {
let removeRuleIds: number[] = []
if (regexFilter in savedRegexFilters) {
// We need to remove the old rule.
removeRuleIds.push(savedRegexFilters[regexFilter].id)
delete savedRegexFilters[regexFilter]
}

await browser.declarativeNetRequest.updateDynamicRules(
{
// We need to add the new rule.
addRules: [
{
id,
priority: 1,
action: {
type: 'redirect',
redirect: { regexSubstitution }
},
condition: {
regexFilter,
excludedInitiatorDomains: [state.gwURL.host],
resourceTypes: [
'csp_report',
'font',
'image',
'main_frame',
'media',
'object',
'other',
'ping',
'script',
'stylesheet',
'sub_frame',
'webbundle',
'websocket',
'webtransport',
'xmlhttprequest'
]
}
}
],
addRules: [generateRule(regexFilter, regexSubstitution)],
// We need to remove the old rules.
removeRuleIds: await browser.declarativeNetRequest.getDynamicRules().then((rules) => rules.map((rule) => rule.id))
removeRuleIds
}
)
// We need to save the regex filter and ID to check if the rule already exists later.
savedRegexFilters.set(regexFilter, id.toString())
}

// async call to reconcile rules and remove old ones.
await reconcileRulesAndRemoveOld(state)
}
}

0 comments on commit 2d2124c

Please sign in to comment.