sanitizer.go 12 KB

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