testing for safelink

This commit is contained in:
BlueSkiesAndGreenPastures
2025-06-20 09:41:37 -05:00
parent 014c78fa06
commit 0a5d9f0452
3 changed files with 184 additions and 32 deletions
+3 -3
View File
@@ -75,7 +75,7 @@ export class EventCache {
`[EventCache] smartUpdateUrl called for url: ${event.url}, action: ${event.action}`, `[EventCache] smartUpdateUrl called for url: ${event.url}, action: ${event.action}`,
) )
if (event.action === ToolsOzoneSafelinkDefs.REMOVERULE) { if (event.action === ToolsOzoneSafelinkDefs.REMOVERULE) {
// If the action is to remove the rule, delete it from the cache // If the action is to remove the rule, update it from the cache
this.insert(event.url, event) this.insert(event.url, event)
redirectLogger.info(`[EventCache] Removed rule for url: ${event.url}`) redirectLogger.info(`[EventCache] Removed rule for url: ${event.url}`)
return return
@@ -102,13 +102,13 @@ export class EventCache {
smartUpdate(event: ToolsOzoneSafelinkDefs.Event) { smartUpdate(event: ToolsOzoneSafelinkDefs.Event) {
if (event.pattern === ToolsOzoneSafelinkDefs.DOMAIN) { if (event.pattern === ToolsOzoneSafelinkDefs.DOMAIN) {
redirectLogger.info( redirectLogger.info(
`[EventCache] smartUpdate called for domain event: ${event.url}, performing$ ${event.action}`, `[EventCache] smartUpdate called for domain event: ${event.url}, performing ${event.action}`,
) )
return this.smartUpdateDomain(event) return this.smartUpdateDomain(event)
} }
if (event.pattern === ToolsOzoneSafelinkDefs.URL) { if (event.pattern === ToolsOzoneSafelinkDefs.URL) {
redirectLogger.info( redirectLogger.info(
`[EventCache] smartUpdate called for domain event: ${event.url}`, `[EventCache] smartUpdate called for url event: ${event.url}`,
) )
return this.smartUpdateUrl(event) return this.smartUpdateUrl(event)
} }
+48 -27
View File
@@ -55,11 +55,16 @@ export default function (ctx: AppContext, app: Express) {
rulePresent.eventType === ToolsOzoneSafelinkDefs.REMOVERULE rulePresent.eventType === ToolsOzoneSafelinkDefs.REMOVERULE
) { ) {
redirectLogger.info( redirectLogger.info(
`No rule or remove rule matched for ${rulePresent.url}`, `No rule or Remove rule matched for ${rulePresent.url}`,
) )
const escaped = escapeHTML(url.href) const escaped = escapeHTML(url.href)
const html = safe_redirect(escaped)
return safe_redirect(escaped) res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(html),
})
res.end(html) // Critical - must call end()
return
} }
if ( if (
@@ -68,8 +73,13 @@ export default function (ctx: AppContext, app: Express) {
) { ) {
redirectLogger.info(`Whitelist rule matched for ${rulePresent.url}`) redirectLogger.info(`Whitelist rule matched for ${rulePresent.url}`)
const escaped = escapeHTML(url.href) const escaped = escapeHTML(url.href)
const html = safe_redirect(escaped)
return safe_redirect(escaped) res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(html),
})
res.end(html) // Critical - must call end()
return
} }
if ( if (
@@ -78,39 +88,50 @@ export default function (ctx: AppContext, app: Express) {
) { ) {
redirectLogger.info(`Block rule matched for ${rulePresent.url}`) redirectLogger.info(`Block rule matched for ${rulePresent.url}`)
res.setHeader('Cache-Control', 'no-store') res.setHeader('Cache-Control', 'no-store')
res.status(403) const html = warnRedirect(
return res.send( 'Blocked Link',
warnRedirect( 'This link has been identified as malicious, it has been blocked to protect your account and data',
'Blocked Link', 'Go Back To BlueSky',
'This link has been identified as malicious, it has been blocked to protect your account and data', 'DANGER',
'Go Back To BlueSky', escapeHTML(url.toString()),
'DANGER', `https://${ctx.cfg.service.appHostname}`,
escapeHTML(url.toString()),
`https://${ctx.cfg.service.appHostname}`,
),
) )
res.writeHead(403, {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(html),
})
res.end(html) // Critical - must call end()
return
} }
if (rulePresent && rulePresent.action === ToolsOzoneSafelinkDefs.WARN) { if (rulePresent && rulePresent.action === ToolsOzoneSafelinkDefs.WARN) {
redirectLogger.info(`Warn rule matched for ${rulePresent.url}`) redirectLogger.info(`Warn rule matched for ${rulePresent.url}`)
res.setHeader('Cache-Control', 'no-store') res.setHeader('Cache-Control', 'no-store')
res.status(403) const html = warnRedirect(
const escaped = escapeHTML(url.href) 'Blocked Link',
return res.send( 'This link has been identified as malicious, it has been blocked to protect your account and data',
warnRedirect( 'Go Back To BlueSky',
'Warning: Potentially Malicious Link', 'DANGER',
'This link could be malicious, proceed at your own risk', escapeHTML(url.toString()),
'Proceed at Your Own Risk', `https://${ctx.cfg.service.appHostname}`,
'WARNING',
escapeHTML(url.toString()),
escaped, // Pass the actual URL as returnUrl so the button proceeds to the link
),
) )
res.writeHead(403, {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(html),
})
res.end(html) // Critical - must call end()
return
} }
} }
const escaped = escapeHTML(url.href) const escaped = escapeHTML(url.href)
return safe_redirect(escaped) const html = safe_redirect(escaped)
res.writeHead(200, {
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(html),
})
res.end(html) // Critical - must call end()
return
}), }),
) )
} }
+133 -2
View File
@@ -1,7 +1,9 @@
import assert from 'node:assert' import assert from 'node:assert'
import {AddressInfo} from 'node:net' import {type AddressInfo} from 'node:net'
import {after, before, describe, it} from 'node:test' import {after, before, describe, it} from 'node:test'
import {ToolsOzoneSafelinkDefs} from '@atproto/api'
import {Database, envToCfg, LinkService, readEnv} from '../src/index.js' import {Database, envToCfg, LinkService, readEnv} from '../src/index.js'
describe('link service', async () => { describe('link service', async () => {
@@ -15,6 +17,10 @@ describe('link service', async () => {
appHostname: 'test.bsky.app', appHostname: 'test.bsky.app',
dbPostgresSchema: 'link_test', dbPostgresSchema: 'link_test',
dbPostgresUrl: process.env.DB_POSTGRES_URL, dbPostgresUrl: process.env.DB_POSTGRES_URL,
safelink: 1,
ozoneUrl: 'http://localhost:2583',
ozoneAgentHandle: 'mod-authority.test',
ozoneAgentPass: 'hunter2',
}) })
const migrateDb = Database.postgres({ const migrateDb = Database.postgres({
url: cfg.db.url, url: cfg.db.url,
@@ -26,8 +32,79 @@ describe('link service', async () => {
await linkService.start() await linkService.start()
const {port} = linkService.server?.address() as AddressInfo const {port} = linkService.server?.address() as AddressInfo
baseUrl = `http://localhost:${port}` baseUrl = `http://localhost:${port}`
})
// Ensure blocklist, whitelist, and safelink rules are set up
const now = new Date().toISOString()
linkService.ctx.cfg.eventCache.smartUpdate({
$type: 'tools.ozone.safelink.defs#event',
id: 1,
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
url: 'https://en.wikipedia.org/wiki/Fight_Club',
pattern: ToolsOzoneSafelinkDefs.URL,
action: ToolsOzoneSafelinkDefs.BLOCK,
reason: ToolsOzoneSafelinkDefs.SPAM,
createdBy: 'did:example:admin',
createdAt: now,
comment: 'Do not talk about Fight Club',
})
linkService.ctx.cfg.eventCache.smartUpdate({
$type: 'tools.ozone.safelink.defs#event',
id: 2,
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
url: 'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a',
pattern: ToolsOzoneSafelinkDefs.URL,
action: ToolsOzoneSafelinkDefs.BLOCK,
reason: ToolsOzoneSafelinkDefs.SPAM,
createdBy: 'did:example:admin',
createdAt: now,
comment: 'All Bs',
})
linkService.ctx.cfg.eventCache.smartUpdate({
$type: 'tools.ozone.safelink.defs#event',
id: 3,
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
url: 'https://en.wikipedia.org',
pattern: ToolsOzoneSafelinkDefs.DOMAIN,
action: ToolsOzoneSafelinkDefs.WHITELIST,
reason: ToolsOzoneSafelinkDefs.NONE,
createdBy: 'did:example:admin',
createdAt: now,
comment: 'Whitelisting the knowledge base of the internet',
})
linkService.ctx.cfg.eventCache.smartUpdate({
$type: 'tools.ozone.safelink.defs#event',
id: 4,
eventType: ToolsOzoneSafelinkDefs.ADDRULE,
url: 'https://www.instagram.com/teamseshbones/?hl=en',
pattern: ToolsOzoneSafelinkDefs.URL,
action: ToolsOzoneSafelinkDefs.BLOCK,
reason: ToolsOzoneSafelinkDefs.SPAM,
createdBy: 'did:example:admin',
createdAt: now,
comment: 'BONES has been erroneously blocked by due to an error',
})
// Ensure 'later' is after 'now'
const later = new Date(Date.now() + 1000).toISOString()
linkService.ctx.cfg.eventCache.smartUpdate({
$type: 'tools.ozone.safelink.defs#event',
id: 5,
eventType: ToolsOzoneSafelinkDefs.REMOVERULE,
url: 'https://www.instagram.com/teamseshbones/?hl=en',
pattern: ToolsOzoneSafelinkDefs.URL,
action: ToolsOzoneSafelinkDefs.REMOVERULE,
reason: ToolsOzoneSafelinkDefs.NONE,
createdBy: 'did:example:admin',
createdAt: later,
comment:
'BONES has been resurrected to bring good music to the world once again',
})
console.log(
linkService.ctx.cfg.eventCache.smartGet(
'https://www.instagram.com/teamseshbones/?hl=en',
),
)
})
after(async () => { after(async () => {
await linkService?.destroy() await linkService?.destroy()
}) })
@@ -76,6 +153,60 @@ describe('link service', async () => {
assert.strictEqual(json.message, 'Link not found') assert.strictEqual(json.message, 'Link not found')
}) })
it(' Wikipedia whitelisted, url restricted. Redirect safely since wikipedia is whitelisted', async () => {
const urlToRedirect = 'https://en.wikipedia.org/wiki/Fight_Club'
const url = new URL(`${baseUrl}/redirect`)
url.searchParams.set('u', urlToRedirect)
const res = await fetch(url, {redirect: 'manual'})
// The server returns an HTML meta refresh, not a real HTTP redirect
// So status will be 200, not 301/303, and there is no Location header
assert.strictEqual(res.status, 200)
const html = await res.text()
assert.match(html, /meta http-equiv="refresh"/)
assert.match(
html,
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
})
it('Unsafe redirect with block rule, due to the content of webpage.', async () => {
const urlToRedirect =
'https://gist.github.com/MattIPv4/045239bc27b16b2bcf7a3a9a4648c08a'
const url = new URL(`${baseUrl}/redirect`)
url.searchParams.set('u', urlToRedirect)
const res = await fetch(url, {redirect: 'manual'})
// The server returns an HTML meta refresh, not a real HTTP redirect
// So status will be 200, not 301/303, and there is no Location header
assert.strictEqual(res.status, 403)
const html = await res.text()
assert.match(
html,
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
})
it('Rule adjustment, safe redirect, 200 response for Instagram Account of teamsesh Bones', async () => {
// Retrieve the latest event after all updates
const result = linkService.ctx.cfg.eventCache.smartGet(
'https://www.instagram.com/teamseshbones/?hl=en',
)
assert(result, 'Expected event not found in eventCache')
assert.strictEqual(result.eventType, ToolsOzoneSafelinkDefs.REMOVERULE)
const urlToRedirect = 'https://www.instagram.com/teamseshbones/?hl=en'
const url = new URL(`${baseUrl}/redirect`)
url.searchParams.set('u', urlToRedirect)
const res = await fetch(url, {redirect: 'manual'})
// The server returns an HTML meta refresh, not a real HTTP redirect
// So status will be 200, not 301/303, and there is no Location header
assert.strictEqual(res.status, 200)
const html = await res.text()
assert.match(html, /meta http-equiv="refresh"/)
assert.match(
html,
new RegExp(urlToRedirect.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
)
})
async function getRedirect(link: string): Promise<[number, string]> { async function getRedirect(link: string): Promise<[number, string]> {
const url = new URL(link) const url = new URL(link)
const base = new URL(baseUrl) const base = new URL(baseUrl)