1+ const dns = require ( "node:dns" ) ;
2+ const ipaddr = require ( "ipaddr.js" ) ;
3+ const undici = require ( "undici" ) ;
14const NodeHelper = require ( "node_helper" ) ;
25const Log = require ( "logger" ) ;
36const NewsfeedFetcher = require ( "./newsfeedfetcher" ) ;
@@ -22,18 +25,53 @@ module.exports = NodeHelper.create({
2225 * Checks whether a URL can be displayed in an iframe by inspecting
2326 * X-Frame-Options and Content-Security-Policy headers server-side.
2427 * @param {string } url The article URL to check.
28+ * @returns {null } sendSocketNotification
2529 */
2630 async checkArticleUrl ( url ) {
2731 try {
28- const response = await fetch ( url , { method : "HEAD" } ) ;
32+ // 1. Parse URL
33+ let parsed ;
34+ try {
35+ parsed = new URL ( url ) ;
36+ } catch {
37+ return this . sendSocketNotification ( "ARTICLE_URL_STATUS" , { url, canFrame : false } ) ;
38+ }
39+
40+ // 2. Protocol validation
41+ if ( ! [ "http:" , "https:" ] . includes ( parsed . protocol ) ) {
42+ return this . sendSocketNotification ( "ARTICLE_URL_STATUS" , { url, canFrame : false } ) ;
43+ }
44+
45+ // 3. Block localhost hostname
46+ if ( parsed . hostname . toLowerCase ( ) === "localhost" ) {
47+ return this . sendSocketNotification ( "ARTICLE_URL_STATUS" , { url, canFrame : false } ) ;
48+ }
49+
50+ // 4. DNS lookup + IP range validation
51+ const { address, family } = await dns . promises . lookup ( parsed . hostname ) ;
52+ if ( ipaddr . process ( address ) . range ( ) !== "unicast" ) {
53+ Log . warn ( `SSRF blocked in checkArticleUrl: ${ url } ` ) ;
54+ return this . sendSocketNotification ( "ARTICLE_URL_STATUS" , { url, canFrame : false } ) ;
55+ }
56+
57+ // 5. Pin IP to prevent DNS rebinding
58+ const dispatcher = new undici . Agent ( {
59+ connect : {
60+ lookup : ( _h , _o , cb ) => {
61+ process . nextTick ( ( ) => cb ( null , [ { address, family } ] ) ) ;
62+ }
63+ }
64+ } ) ;
65+
66+ // 6. Make request with pinned IP
67+ const response = await undici . fetch ( url , { dispatcher, method : "HEAD" } ) ;
2968 const xfo = response . headers . get ( "x-frame-options" ) ;
3069 const csp = response . headers . get ( "content-security-policy" ) ;
31- // sameorigin also blocks since the article is on a different origin than MM
3270 const blockedByXFO = xfo && [ "deny" , "sameorigin" ] . includes ( xfo . toLowerCase ( ) . trim ( ) ) ;
3371 const blockedByCSP = csp && ( / f r a m e - a n c e s t o r s \s + [ ' " ] ? n o n e [ ' " ] ? / ) . test ( csp ) ;
72+
3473 this . sendSocketNotification ( "ARTICLE_URL_STATUS" , { url, canFrame : ! blockedByXFO && ! blockedByCSP } ) ;
3574 } catch {
36- // Network error or HEAD not supported — let the browser try the iframe anyway
3775 this . sendSocketNotification ( "ARTICLE_URL_STATUS" , { url, canFrame : true } ) ;
3876 }
3977 } ,
0 commit comments