sanitizer.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package sanitizer // import "miniflux.app/v2/internal/reader/sanitizer"
  4. import (
  5. "bytes"
  6. "fmt"
  7. "io"
  8. "regexp"
  9. "strconv"
  10. "strings"
  11. "miniflux.app/v2/internal/config"
  12. "miniflux.app/v2/internal/urllib"
  13. "golang.org/x/net/html"
  14. )
  15. var (
  16. youtubeEmbedRegex = regexp.MustCompile(`//www\.youtube\.com/embed/(.*)`)
  17. )
  18. // Sanitize returns safe HTML.
  19. func Sanitize(baseURL, input string) string {
  20. var buffer bytes.Buffer
  21. var tagStack []string
  22. var parentTag string
  23. blacklistedTagDepth := 0
  24. tokenizer := html.NewTokenizer(bytes.NewBufferString(input))
  25. for {
  26. if tokenizer.Next() == html.ErrorToken {
  27. err := tokenizer.Err()
  28. if err == io.EOF {
  29. return buffer.String()
  30. }
  31. return ""
  32. }
  33. token := tokenizer.Token()
  34. switch token.Type {
  35. case html.TextToken:
  36. if blacklistedTagDepth > 0 {
  37. continue
  38. }
  39. // An iframe element never has fallback content.
  40. // See https://www.w3.org/TR/2010/WD-html5-20101019/the-iframe-element.html#the-iframe-element
  41. if parentTag == "iframe" {
  42. continue
  43. }
  44. buffer.WriteString(html.EscapeString(token.Data))
  45. case html.StartTagToken:
  46. tagName := token.DataAtom.String()
  47. parentTag = tagName
  48. if !isPixelTracker(tagName, token.Attr) && isValidTag(tagName) {
  49. attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
  50. if hasRequiredAttributes(tagName, attrNames) {
  51. if len(attrNames) > 0 {
  52. buffer.WriteString("<" + tagName + " " + htmlAttributes + ">")
  53. } else {
  54. buffer.WriteString("<" + tagName + ">")
  55. }
  56. tagStack = append(tagStack, tagName)
  57. }
  58. } else if isBlockedTag(tagName) {
  59. blacklistedTagDepth++
  60. }
  61. case html.EndTagToken:
  62. tagName := token.DataAtom.String()
  63. if isValidTag(tagName) && inList(tagName, tagStack) {
  64. buffer.WriteString(fmt.Sprintf("</%s>", tagName))
  65. } else if isBlockedTag(tagName) {
  66. blacklistedTagDepth--
  67. }
  68. case html.SelfClosingTagToken:
  69. tagName := token.DataAtom.String()
  70. if !isPixelTracker(tagName, token.Attr) && isValidTag(tagName) {
  71. attrNames, htmlAttributes := sanitizeAttributes(baseURL, tagName, token.Attr)
  72. if hasRequiredAttributes(tagName, attrNames) {
  73. if len(attrNames) > 0 {
  74. buffer.WriteString("<" + tagName + " " + htmlAttributes + "/>")
  75. } else {
  76. buffer.WriteString("<" + tagName + "/>")
  77. }
  78. }
  79. }
  80. }
  81. }
  82. }
  83. func sanitizeAttributes(baseURL, tagName string, attributes []html.Attribute) ([]string, string) {
  84. var htmlAttrs, attrNames []string
  85. var err error
  86. var isImageLargerThanLayout bool
  87. var isAnchorLink bool
  88. if tagName == "img" {
  89. imgWidth := getIntegerAttributeValue("width", attributes)
  90. isImageLargerThanLayout = imgWidth > 750
  91. }
  92. for _, attribute := range attributes {
  93. value := attribute.Val
  94. if !isValidAttribute(tagName, attribute.Key) {
  95. continue
  96. }
  97. if (tagName == "img" || tagName == "source") && attribute.Key == "srcset" {
  98. value = sanitizeSrcsetAttr(baseURL, value)
  99. }
  100. if tagName == "img" && (attribute.Key == "width" || attribute.Key == "height") {
  101. if !isPositiveInteger(value) {
  102. continue
  103. }
  104. if isImageLargerThanLayout {
  105. continue
  106. }
  107. }
  108. if isExternalResourceAttribute(attribute.Key) {
  109. if tagName == "iframe" {
  110. if isValidIframeSource(baseURL, attribute.Val) {
  111. value = rewriteIframeURL(attribute.Val)
  112. } else {
  113. continue
  114. }
  115. } else if tagName == "img" && attribute.Key == "src" && isValidDataAttribute(attribute.Val) {
  116. value = attribute.Val
  117. } else if isAnchor("a", attribute) {
  118. value = attribute.Val
  119. isAnchorLink = true
  120. } else {
  121. value, err = urllib.AbsoluteURL(baseURL, value)
  122. if err != nil {
  123. continue
  124. }
  125. if !hasValidURIScheme(value) || isBlockedResource(value) {
  126. continue
  127. }
  128. }
  129. }
  130. attrNames = append(attrNames, attribute.Key)
  131. htmlAttrs = append(htmlAttrs, fmt.Sprintf(`%s="%s"`, attribute.Key, html.EscapeString(value)))
  132. }
  133. if !isAnchorLink {
  134. extraAttrNames, extraHTMLAttributes := getExtraAttributes(tagName)
  135. if len(extraAttrNames) > 0 {
  136. attrNames = append(attrNames, extraAttrNames...)
  137. htmlAttrs = append(htmlAttrs, extraHTMLAttributes...)
  138. }
  139. }
  140. return attrNames, strings.Join(htmlAttrs, " ")
  141. }
  142. func getExtraAttributes(tagName string) ([]string, []string) {
  143. switch tagName {
  144. case "a":
  145. return []string{"rel", "target", "referrerpolicy"}, []string{`rel="noopener noreferrer"`, `target="_blank"`, `referrerpolicy="no-referrer"`}
  146. case "video", "audio":
  147. return []string{"controls"}, []string{"controls"}
  148. case "iframe":
  149. return []string{"sandbox", "loading"}, []string{`sandbox="allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox"`, `loading="lazy"`}
  150. case "img":
  151. return []string{"loading"}, []string{`loading="lazy"`}
  152. default:
  153. return nil, nil
  154. }
  155. }
  156. func isValidTag(tagName string) bool {
  157. for element := range getTagAllowList() {
  158. if tagName == element {
  159. return true
  160. }
  161. }
  162. return false
  163. }
  164. func isValidAttribute(tagName, attributeName string) bool {
  165. for element, attributes := range getTagAllowList() {
  166. if tagName == element {
  167. if inList(attributeName, attributes) {
  168. return true
  169. }
  170. }
  171. }
  172. return false
  173. }
  174. func isExternalResourceAttribute(attribute string) bool {
  175. switch attribute {
  176. case "src", "href", "poster", "cite":
  177. return true
  178. default:
  179. return false
  180. }
  181. }
  182. func isPixelTracker(tagName string, attributes []html.Attribute) bool {
  183. if tagName == "img" {
  184. hasHeight := false
  185. hasWidth := false
  186. for _, attribute := range attributes {
  187. if attribute.Key == "height" && attribute.Val == "1" {
  188. hasHeight = true
  189. }
  190. if attribute.Key == "width" && attribute.Val == "1" {
  191. hasWidth = true
  192. }
  193. }
  194. return hasHeight && hasWidth
  195. }
  196. return false
  197. }
  198. func hasRequiredAttributes(tagName string, attributes []string) bool {
  199. elements := make(map[string][]string)
  200. elements["a"] = []string{"href"}
  201. elements["iframe"] = []string{"src"}
  202. elements["img"] = []string{"src"}
  203. elements["source"] = []string{"src", "srcset"}
  204. for element, attrs := range elements {
  205. if tagName == element {
  206. for _, attribute := range attributes {
  207. for _, attr := range attrs {
  208. if attr == attribute {
  209. return true
  210. }
  211. }
  212. }
  213. return false
  214. }
  215. }
  216. return true
  217. }
  218. // See https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
  219. func hasValidURIScheme(src string) bool {
  220. whitelist := []string{
  221. "apt:",
  222. "bitcoin:",
  223. "callto:",
  224. "dav:",
  225. "davs:",
  226. "ed2k://",
  227. "facetime://",
  228. "feed:",
  229. "ftp://",
  230. "geo:",
  231. "gopher://",
  232. "git://",
  233. "http://",
  234. "https://",
  235. "irc://",
  236. "irc6://",
  237. "ircs://",
  238. "itms://",
  239. "itms-apps://",
  240. "magnet:",
  241. "mailto:",
  242. "news:",
  243. "nntp:",
  244. "rtmp://",
  245. "sip:",
  246. "sips:",
  247. "skype:",
  248. "spotify:",
  249. "ssh://",
  250. "sftp://",
  251. "steam://",
  252. "svn://",
  253. "svn+ssh://",
  254. "tel:",
  255. "webcal://",
  256. "xmpp:",
  257. // iOS Apps
  258. "opener://", // https://www.opener.link
  259. "hack://", // https://apps.apple.com/it/app/hack-for-hacker-news-reader/id1464477788?l=en-GB
  260. }
  261. for _, prefix := range whitelist {
  262. if strings.HasPrefix(src, prefix) {
  263. return true
  264. }
  265. }
  266. return false
  267. }
  268. func isBlockedResource(src string) bool {
  269. blacklist := []string{
  270. "feedsportal.com",
  271. "api.flattr.com",
  272. "stats.wordpress.com",
  273. "plus.google.com/share",
  274. "twitter.com/share",
  275. "feeds.feedburner.com",
  276. }
  277. for _, element := range blacklist {
  278. if strings.Contains(src, element) {
  279. return true
  280. }
  281. }
  282. return false
  283. }
  284. func isValidIframeSource(baseURL, src string) bool {
  285. whitelist := []string{
  286. "//www.youtube.com",
  287. "http://www.youtube.com",
  288. "https://www.youtube.com",
  289. "https://www.youtube-nocookie.com",
  290. "http://player.vimeo.com",
  291. "https://player.vimeo.com",
  292. "http://www.dailymotion.com",
  293. "https://www.dailymotion.com",
  294. "http://vk.com",
  295. "https://vk.com",
  296. "http://soundcloud.com",
  297. "https://soundcloud.com",
  298. "http://w.soundcloud.com",
  299. "https://w.soundcloud.com",
  300. "http://bandcamp.com",
  301. "https://bandcamp.com",
  302. "https://cdn.embedly.com",
  303. "https://player.bilibili.com",
  304. "https://player.twitch.tv",
  305. }
  306. // allow iframe from same origin
  307. if urllib.Domain(baseURL) == urllib.Domain(src) {
  308. return true
  309. }
  310. // allow iframe from custom invidious instance
  311. if config.Opts != nil && config.Opts.InvidiousInstance() == urllib.Domain(src) {
  312. return true
  313. }
  314. for _, prefix := range whitelist {
  315. if strings.HasPrefix(src, prefix) {
  316. return true
  317. }
  318. }
  319. return false
  320. }
  321. func getTagAllowList() map[string][]string {
  322. whitelist := make(map[string][]string)
  323. whitelist["img"] = []string{"alt", "title", "src", "srcset", "sizes", "width", "height"}
  324. whitelist["picture"] = []string{}
  325. whitelist["audio"] = []string{"src"}
  326. whitelist["video"] = []string{"poster", "height", "width", "src"}
  327. whitelist["source"] = []string{"src", "type", "srcset", "sizes", "media"}
  328. whitelist["dt"] = []string{"id"}
  329. whitelist["dd"] = []string{"id"}
  330. whitelist["dl"] = []string{"id"}
  331. whitelist["table"] = []string{}
  332. whitelist["caption"] = []string{}
  333. whitelist["thead"] = []string{}
  334. whitelist["tfooter"] = []string{}
  335. whitelist["tr"] = []string{}
  336. whitelist["td"] = []string{"rowspan", "colspan"}
  337. whitelist["th"] = []string{"rowspan", "colspan"}
  338. whitelist["h1"] = []string{"id"}
  339. whitelist["h2"] = []string{"id"}
  340. whitelist["h3"] = []string{"id"}
  341. whitelist["h4"] = []string{"id"}
  342. whitelist["h5"] = []string{"id"}
  343. whitelist["h6"] = []string{"id"}
  344. whitelist["strong"] = []string{}
  345. whitelist["em"] = []string{}
  346. whitelist["code"] = []string{}
  347. whitelist["pre"] = []string{}
  348. whitelist["blockquote"] = []string{}
  349. whitelist["q"] = []string{"cite"}
  350. whitelist["p"] = []string{}
  351. whitelist["ul"] = []string{"id"}
  352. whitelist["li"] = []string{"id"}
  353. whitelist["ol"] = []string{"id"}
  354. whitelist["br"] = []string{}
  355. whitelist["del"] = []string{}
  356. whitelist["a"] = []string{"href", "title", "id"}
  357. whitelist["figure"] = []string{}
  358. whitelist["figcaption"] = []string{}
  359. whitelist["cite"] = []string{}
  360. whitelist["time"] = []string{"datetime"}
  361. whitelist["abbr"] = []string{"title"}
  362. whitelist["acronym"] = []string{"title"}
  363. whitelist["wbr"] = []string{}
  364. whitelist["dfn"] = []string{}
  365. whitelist["sub"] = []string{}
  366. whitelist["sup"] = []string{"id"}
  367. whitelist["var"] = []string{}
  368. whitelist["samp"] = []string{}
  369. whitelist["s"] = []string{}
  370. whitelist["del"] = []string{}
  371. whitelist["ins"] = []string{}
  372. whitelist["kbd"] = []string{}
  373. whitelist["rp"] = []string{}
  374. whitelist["rt"] = []string{}
  375. whitelist["rtc"] = []string{}
  376. whitelist["ruby"] = []string{}
  377. whitelist["iframe"] = []string{"width", "height", "frameborder", "src", "allowfullscreen"}
  378. return whitelist
  379. }
  380. func inList(needle string, haystack []string) bool {
  381. for _, element := range haystack {
  382. if element == needle {
  383. return true
  384. }
  385. }
  386. return false
  387. }
  388. func rewriteIframeURL(link string) string {
  389. matches := youtubeEmbedRegex.FindStringSubmatch(link)
  390. if len(matches) == 2 {
  391. return config.Opts.YouTubeEmbedUrlOverride() + matches[1]
  392. }
  393. return link
  394. }
  395. func isBlockedTag(tagName string) bool {
  396. blacklist := []string{
  397. "noscript",
  398. "script",
  399. "style",
  400. }
  401. for _, element := range blacklist {
  402. if element == tagName {
  403. return true
  404. }
  405. }
  406. return false
  407. }
  408. func sanitizeSrcsetAttr(baseURL, value string) string {
  409. imageCandidates := ParseSrcSetAttribute(value)
  410. for _, imageCandidate := range imageCandidates {
  411. absoluteURL, err := urllib.AbsoluteURL(baseURL, imageCandidate.ImageURL)
  412. if err == nil {
  413. imageCandidate.ImageURL = absoluteURL
  414. }
  415. }
  416. return imageCandidates.String()
  417. }
  418. func isValidDataAttribute(value string) bool {
  419. var dataAttributeAllowList = []string{
  420. "data:image/avif",
  421. "data:image/apng",
  422. "data:image/png",
  423. "data:image/svg",
  424. "data:image/svg+xml",
  425. "data:image/jpg",
  426. "data:image/jpeg",
  427. "data:image/gif",
  428. "data:image/webp",
  429. }
  430. for _, prefix := range dataAttributeAllowList {
  431. if strings.HasPrefix(value, prefix) {
  432. return true
  433. }
  434. }
  435. return false
  436. }
  437. func isAnchor(tagName string, attribute html.Attribute) bool {
  438. return tagName == "a" && attribute.Key == "href" && strings.HasPrefix(attribute.Val, "#")
  439. }
  440. func isPositiveInteger(value string) bool {
  441. if number, err := strconv.Atoi(value); err == nil {
  442. return number > 0
  443. }
  444. return false
  445. }
  446. func getAttributeValue(name string, attributes []html.Attribute) string {
  447. for _, attribute := range attributes {
  448. if attribute.Key == name {
  449. return attribute.Val
  450. }
  451. }
  452. return ""
  453. }
  454. func getIntegerAttributeValue(name string, attributes []html.Attribute) int {
  455. number, _ := strconv.Atoi(getAttributeValue(name, attributes))
  456. return number
  457. }