diff --git a/bskylink/src/cache/cache.ts b/bskylink/src/cache/cache.ts index 737e2836d2..7c787319cc 100644 --- a/bskylink/src/cache/cache.ts +++ b/bskylink/src/cache/cache.ts @@ -75,7 +75,7 @@ export class EventCache { `[EventCache] smartUpdateUrl called for url: ${event.url}, action: ${event.action}`, ) 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) redirectLogger.info(`[EventCache] Removed rule for url: ${event.url}`) return @@ -102,13 +102,13 @@ export class EventCache { smartUpdate(event: ToolsOzoneSafelinkDefs.Event) { if (event.pattern === ToolsOzoneSafelinkDefs.DOMAIN) { 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) } if (event.pattern === ToolsOzoneSafelinkDefs.URL) { redirectLogger.info( - `[EventCache] smartUpdate called for domain event: ${event.url}`, + `[EventCache] smartUpdate called for url event: ${event.url}`, ) return this.smartUpdateUrl(event) } diff --git a/bskylink/src/routes/redirect.ts b/bskylink/src/routes/redirect.ts index e34930ca24..b38619a8cb 100644 --- a/bskylink/src/routes/redirect.ts +++ b/bskylink/src/routes/redirect.ts @@ -55,11 +55,16 @@ export default function (ctx: AppContext, app: Express) { rulePresent.eventType === ToolsOzoneSafelinkDefs.REMOVERULE ) { 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) - - 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 } if ( @@ -68,8 +73,13 @@ export default function (ctx: AppContext, app: Express) { ) { redirectLogger.info(`Whitelist rule matched for ${rulePresent.url}`) 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 } if ( @@ -78,39 +88,50 @@ export default function (ctx: AppContext, app: Express) { ) { redirectLogger.info(`Block rule matched for ${rulePresent.url}`) res.setHeader('Cache-Control', 'no-store') - res.status(403) - return res.send( - warnRedirect( - 'Blocked Link', - 'This link has been identified as malicious, it has been blocked to protect your account and data', - 'Go Back To BlueSky', - 'DANGER', - escapeHTML(url.toString()), - `https://${ctx.cfg.service.appHostname}`, - ), + const html = warnRedirect( + 'Blocked Link', + 'This link has been identified as malicious, it has been blocked to protect your account and data', + 'Go Back To BlueSky', + 'DANGER', + 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) { redirectLogger.info(`Warn rule matched for ${rulePresent.url}`) res.setHeader('Cache-Control', 'no-store') - res.status(403) - const escaped = escapeHTML(url.href) - return res.send( - warnRedirect( - 'Warning: Potentially Malicious Link', - 'This link could be malicious, proceed at your own risk', - 'Proceed at Your Own Risk', - 'WARNING', - escapeHTML(url.toString()), - escaped, // Pass the actual URL as returnUrl so the button proceeds to the link - ), + const html = warnRedirect( + 'Blocked Link', + 'This link has been identified as malicious, it has been blocked to protect your account and data', + 'Go Back To BlueSky', + 'DANGER', + 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 } } 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 }), ) } diff --git a/bskylink/tests/index.ts b/bskylink/tests/index.ts index c5604c7a12..5486e5ba80 100644 --- a/bskylink/tests/index.ts +++ b/bskylink/tests/index.ts @@ -1,7 +1,9 @@ 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 {ToolsOzoneSafelinkDefs} from '@atproto/api' + import {Database, envToCfg, LinkService, readEnv} from '../src/index.js' describe('link service', async () => { @@ -15,6 +17,10 @@ describe('link service', async () => { appHostname: 'test.bsky.app', dbPostgresSchema: 'link_test', dbPostgresUrl: process.env.DB_POSTGRES_URL, + safelink: 1, + ozoneUrl: 'http://localhost:2583', + ozoneAgentHandle: 'mod-authority.test', + ozoneAgentPass: 'hunter2', }) const migrateDb = Database.postgres({ url: cfg.db.url, @@ -26,8 +32,79 @@ describe('link service', async () => { await linkService.start() const {port} = linkService.server?.address() as AddressInfo 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 () => { await linkService?.destroy() }) @@ -76,6 +153,60 @@ describe('link service', async () => { 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]> { const url = new URL(link) const base = new URL(baseUrl)