diff --git a/bskylink/package.json b/bskylink/package.json index 21fe123aba..e254806657 100644 --- a/bskylink/package.json +++ b/bskylink/package.json @@ -18,6 +18,7 @@ "kysely": "^0.27.3", "pg": "^8.12.0", "pino": "^9.2.0", + "uhtml": "^4.7.1", "uint8arrays": "^5.1.0" }, "devDependencies": { diff --git a/bskylink/src/html/linkRedirectContents.ts b/bskylink/src/html/linkRedirectContents.ts new file mode 100644 index 0000000000..a3d31de50e --- /dev/null +++ b/bskylink/src/html/linkRedirectContents.ts @@ -0,0 +1,21 @@ +import {type Hole, html} from 'uhtml' + +export function linkRedirectContents(link: string): Hole { + return html` + + + + + + + + + + ` +} diff --git a/bskylink/src/html/linkWarningContents.ts b/bskylink/src/html/linkWarningContents.ts new file mode 100644 index 0000000000..70b91ea23a --- /dev/null +++ b/bskylink/src/html/linkWarningContents.ts @@ -0,0 +1,29 @@ +import {type Hole, html} from 'uhtml' + +export function linkWarningContents(opts: { + type: 'warn' | 'block' + link: string +}): Hole { + return html` +
⚠️
+

+ ${opts.type === 'warn' ? 'Potentially Dangerous Link' : 'Blocked Link'} +

+

+ ${opts.type === 'warn' + ? 'This link may be malicious. You should proceed at your own risk.' + : 'This link has been identified as malicious and has blocked for your safety.'} +

+
+

${opts.link}

+
+
+ ${opts.type === 'warn' + ? html`Continue Anyway` + : null} + Return to Bluesky +
+ ` +} diff --git a/bskylink/src/html/linkWarningLayout.ts b/bskylink/src/html/linkWarningLayout.ts new file mode 100644 index 0000000000..cc5c4d4ac9 --- /dev/null +++ b/bskylink/src/html/linkWarningLayout.ts @@ -0,0 +1,133 @@ +import {type Hole, html} from 'uhtml' + +export function linkWarningLayout( + title: string, + containerContents: Hole, +): Hole { + return html` + + + + + + + + + ${title} + + + +
${containerContents}
+ + + ` +} diff --git a/bskylink/src/routes/redirect.ts b/bskylink/src/routes/redirect.ts index 436e87400a..b238e105d5 100644 --- a/bskylink/src/routes/redirect.ts +++ b/bskylink/src/routes/redirect.ts @@ -2,10 +2,13 @@ import assert from 'node:assert' import {ToolsOzoneSafelinkDefs} from '@atproto/api' import {DAY, SECOND} from '@atproto/common' -import escapeHTML from 'escape-html' import {type Express} from 'express' +import {type Hole} from 'uhtml' import {type AppContext} from '../context.js' +import {linkRedirectContents} from '../html/linkRedirectContents.js' +import {linkWarningContents} from '../html/linkWarningContents.js' +import {linkWarningLayout} from '../html/linkWarningLayout.js' import {redirectLogger} from '../logger.js' import {handler} from './util.js' @@ -41,247 +44,65 @@ export default function (ctx: AppContext, app: Express) { return res.status(302).end() } + // Default to a max age header res.setHeader('Cache-Control', `max-age=${(7 * DAY) / SECOND}`) - res.type('html') res.status(200) + res.type('html') + + let hole: Hole | undefined if (ctx.cfg.service.safelinkEnabled) { const rulePresent: ToolsOzoneSafelinkDefs.Event | undefined = ctx.cfg.eventCache.smartGet(link) - // begin link safety checks - if ( - rulePresent && - rulePresent.eventType === ToolsOzoneSafelinkDefs.REMOVERULE - ) { - redirectLogger.info( - `No rule or Remove rule matched for ${rulePresent.url}`, - ) - const escaped = escapeHTML(url.href) - 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 ( - rulePresent && - rulePresent.action === ToolsOzoneSafelinkDefs.WHITELIST - ) { - redirectLogger.info(`Whitelist rule matched for ${rulePresent.url}`) - const escaped = escapeHTML(url.href) - 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 ( - rulePresent && - rulePresent.action === ToolsOzoneSafelinkDefs.BLOCK - ) { - redirectLogger.info(`Block rule matched for ${rulePresent.url}`) - res.setHeader('Cache-Control', 'no-store') - 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(200, { - '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') - const html = warnRedirect( - 'Warning: Malicious Link', - 'This link has been identified as malicious, continue at your own risk', - 'continue at your own risk', - 'DANGER', - escapeHTML(url.toString()), - `https://${ctx.cfg.service.appHostname}`, - ) - res.writeHead(200, { - 'Content-Type': 'text/html', - 'Content-Length': Buffer.byteLength(html), - }) - res.end(html) // Critical - must call end() - return + if (rulePresent) { + switch (rulePresent.action) { + case ToolsOzoneSafelinkDefs.BLOCK: + hole = linkWarningLayout( + 'Blocked Link Warning', + linkWarningContents({ + type: 'block', + link: url.href, + }), + ) + res.setHeader('Cache-Control', 'no-store') + redirectLogger.info(`Block rule matched for ${rulePresent.url}`) + break + case ToolsOzoneSafelinkDefs.WARN: + hole = linkWarningLayout( + 'Malicious Link Warning', + linkWarningContents({ + type: 'warn', + link: url.href, + }), + ) + res.setHeader('Cache-Control', 'no-store') + redirectLogger.info(`Warn rule matched for ${rulePresent.url}`) + break + case ToolsOzoneSafelinkDefs.WHITELIST: + redirectLogger.info( + `Whitelist rule matched for ${rulePresent.url}`, + ) + break + case ToolsOzoneSafelinkDefs.REMOVERULE: + redirectLogger.info(`Remove rule matched for ${rulePresent.url}`) + break + default: + redirectLogger.warn( + `${rulePresent.action} rule (an unknown rule) matched for ${rulePresent.url}`, + ) + } + } else { + redirectLogger.info(`No rule present for ${rulePresent.url}`) } } - const escaped = escapeHTML(url.href) - 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 there is no hole defined yet, we will create a redirect hole + if (!hole) { + hole = linkRedirectContents(url.href) + } + + return res.end(String(hole)) }), ) } - -const safe_redirect = (escaped: string) => - ` - - - - - - ` - -const warnRedirect = ( - mainText: string, - warningText: string, - buttonText: string, - reason: string, - siteUrl: string, - returnUrl = 'https://bsky.app', -) => { - return ` - - - - - - - - ${mainText} - - - -
-
⚠️
-

${mainText}

-

${escapeHTML(warningText)}

-
- ${escapeHTML(reason)} - ${escapeHTML(siteUrl)} -
- -
- - - ` -} diff --git a/bskylink/yarn.lock b/bskylink/yarn.lock index e72fea0b9b..9ac2dccd7f 100644 --- a/bskylink/yarn.lock +++ b/bskylink/yarn.lock @@ -62,6 +62,11 @@ cborg "^1.6.0" multiformats "^9.5.4" +"@preact/signals-core@^1.8.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@preact/signals-core/-/signals-core-1.10.0.tgz#765eb7045998b98c437e1ad1a660e5ff96a40136" + integrity sha512-qlKeXlfqtlC+sjxCPHt6Sk0/dXBrKZVcPlianqjNc/vW263YBFiP5mRrgKpHoO0q222Thm1TdYQWfCKpbbgvwA== + "@types/cors@^2.8.17": version "2.8.17" resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" @@ -90,6 +95,18 @@ pg-protocol "*" pg-types "^4.0.1" +"@webreflection/signal@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@webreflection/signal/-/signal-2.1.2.tgz#8adcf99b33f7e8ddfade4742b171c1743dc930d2" + integrity sha512-0dW0fstQQkIt588JwhDiPS4xgeeQcQnBHn6MVInrBzmFlnLtzoSJL9G7JqdAlZVVi19tfb8R1QisZIT31cgiug== + +"@webreflection/uparser@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@webreflection/uparser/-/uparser-0.4.0.tgz#49c105455e9482f9a7398f96eb398e421e440f26" + integrity sha512-kAFWUEw5eool295y01VDr+DOsyog6lURX9l288JCJAD2gxc0tFk34dYaAi6O3BbJyfSoncVEV+nw87bsssdppQ== + dependencies: + domconstants "^1.1.6" + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -223,6 +240,11 @@ cors@^2.8.5: object-assign "^4" vary "^1" +custom-function@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/custom-function/-/custom-function-2.0.0.tgz#e421ce1712fa5f8e240a518080a4c82ae97e8606" + integrity sha512-2OPHkZzq3mK1nWpJqWWkGD6Z+0AajNeIxmXl+MRVL8Vysjjf5tf9B5mo713/X2khEwBn/3BKQ7NphpP1vpVKug== + debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -259,6 +281,41 @@ detect-libc@^2.0.1: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domconstants@^1.1.6: + version "1.1.6" + resolved "https://registry.yarnpkg.com/domconstants/-/domconstants-1.1.6.tgz#c6dd4e181a3ddf641a3d2f55ec79569a409d82d4" + integrity sha512-CuaDrThJ4VM+LyZ4ax8n52k0KbLJZtffyGkuj1WhpTRRcSfcy/9DfOBa68jenhX96oNUTunblSJEUNC4baFdmQ== + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.1.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -269,6 +326,11 @@ encodeurl@~1.0.2: resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== +entities@^4.2.0, entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + es-define-property@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" @@ -378,6 +440,11 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== +gc-hook@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/gc-hook/-/gc-hook-0.4.1.tgz#61e0ef4c5c2a13ae6f938cc2b0b9a71a94a12e68" + integrity sha512-uiF+uUftDVLr+VRdudsdsT3/LQYnv2ntwhRH964O7xXDI57Smrek5olv75Wb8Nnz6U+7iVTRXsBlxKcsaDTJTQ== + get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" @@ -425,6 +492,21 @@ hasown@^2.0.0: dependencies: function-bind "^1.1.2" +html-escaper@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-3.0.3.tgz#4d336674652beb1dcbc29ef6b6ba7f6be6fdfed6" + integrity sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ== + +htmlparser2@^9.1.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-9.1.0.tgz#cdb498d8a75a51f739b61d3f718136c369bc8c23" + integrity sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.1.0" + entities "^4.5.0" + http-errors@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -987,6 +1069,27 @@ typescript@^5.4.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.5.tgz#42ccef2c571fdbd0f6718b1d1f5e6e5ef006f611" integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== +udomdiff@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/udomdiff/-/udomdiff-1.1.2.tgz#2979769943afddfb1e6f40e8bda41e431bdcd813" + integrity sha512-v+Z8Jal+GtmKGtJ34GIQlCJAxrDt9kbjpNsNvYoAXFyr4gNfWlD4uJJuoNNu/0UTVaKvQwHaSU095YDl71lKPw== + +uhtml@^4.7.1: + version "4.7.1" + resolved "https://registry.yarnpkg.com/uhtml/-/uhtml-4.7.1.tgz#5a0e9c08aefb08c8e9d59e208d7d52f1646561e3" + integrity sha512-2Nv8m2WTVBAmep42aYDnMDTRf87yHRWFSif9uEqkCB1fgX85q7rxTXkP6PNINCNUs9/KmQJ+RBgSH1BNZmxEtg== + dependencies: + "@webreflection/uparser" "^0.4.0" + custom-function "^2.0.0" + domconstants "^1.1.6" + gc-hook "^0.4.1" + html-escaper "^3.0.3" + htmlparser2 "^9.1.0" + udomdiff "^1.1.2" + optionalDependencies: + "@preact/signals-core" "^1.8.0" + "@webreflection/signal" "^2.1.2" + uint8arrays@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/uint8arrays/-/uint8arrays-3.0.0.tgz#260869efb8422418b6f04e3fac73a3908175c63b"