From bf477c1a53cdbfa14de1d0d95ccf7793f736f2e4 Mon Sep 17 00:00:00 2001 From: Lain Soykaf Date: Thu, 23 Jul 2026 13:02:28 +0400 Subject: [PATCH] fix(deps): replace mdex with earmark parser --- .woodpecker/docker-armv7.yaml | 1 - Dockerfile | 4 - changelog.d/earmark-mdex.security | 2 +- lib/pleroma/formatter.ex | 179 +++++++++++++++++++++--------- mix.exs | 3 +- mix.lock | 7 +- test/pleroma/formatter_test.exs | 82 ++++++++++++++ 7 files changed, 209 insertions(+), 69 deletions(-) diff --git a/.woodpecker/docker-armv7.yaml b/.woodpecker/docker-armv7.yaml index 87a92f96d..f5fb8e981 100644 --- a/.woodpecker/docker-armv7.yaml +++ b/.woodpecker/docker-armv7.yaml @@ -27,7 +27,6 @@ variables: - ELIXIR_TAG=1.17.3-otp-26-alpine@sha256:1671da02865cadad4cdb655c6670a50f0bfc9ead0a3896bd52c673e685f8ad1c - ALPINE_IMG=arm32v7/alpine - ALPINE_VER=3.24.1 - - MDEX_NATIVE_BUILD=true extra_opts: --custom-platform=linux/arm/v7 armv7_push_settings: &armv7_push_settings <<: *armv7_build_settings diff --git a/Dockerfile b/Dockerfile index dc1078e74..5fd1cc0a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,16 +8,12 @@ ARG ALPINE_IMG=alpine FROM ${ELIXIR_IMG}:${ELIXIR_TAG} AS build -ARG MDEX_NATIVE_BUILD=false - COPY . . ENV MIX_ENV=prod -ENV MDEX_NATIVE_BUILD=${MDEX_NATIVE_BUILD} ENV VIX_COMPILATION_MODE=PLATFORM_PROVIDED_LIBVIPS RUN apk add git gcc g++ musl-dev make cmake file-dev vips-dev &&\ - case "${MDEX_NATIVE_BUILD}" in true|1) apk add cargo ;; esac &&\ echo "import Config" > config/prod.secret.exs &&\ mix local.hex --force &&\ mix local.rebar --force &&\ diff --git a/changelog.d/earmark-mdex.security b/changelog.d/earmark-mdex.security index b03da28ab..dc0ee7710 100644 --- a/changelog.d/earmark-mdex.security +++ b/changelog.d/earmark-mdex.security @@ -1 +1 @@ -Replace retired Earmark markdown rendering with MDEx to address stored XSS in link attributes. +Replace retired Earmark rendering with the maintained EarmarkParser and an attribute-escaping renderer. diff --git a/lib/pleroma/formatter.ex b/lib/pleroma/formatter.ex index 61d9bee45..1cb9884a5 100644 --- a/lib/pleroma/formatter.ex +++ b/lib/pleroma/formatter.ex @@ -3,12 +3,15 @@ # SPDX-License-Identifier: AGPL-3.0-only defmodule Pleroma.Formatter do + require Logger + alias Pleroma.HTML alias Pleroma.User @safe_mention_regex ~r/^(\s*(?(@.+?\s+){1,})+)(?.*)/s @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui @markdown_characters_regex ~r/(`|\*|_|{|}|[|]|\(|\)|#|\+|-|\.|!)/ + @markdown_void_elements ~w[area base br col command embed hr img input keygen link meta param source track wbr] defp linkify_opts do Pleroma.Config.get(Pleroma.Formatter) ++ @@ -129,81 +132,147 @@ defmodule Pleroma.Formatter do def markdown_to_html(text, opts) do breaks = Map.get(opts, :breaks, false) - strip_email_autolinks = Map.get(opts, :strip_email_autolinks, false) - text = normalize_markdown_quirks(text) - MDEx.new( - markdown: text, - extension: [autolink: true, strikethrough: true], - parse: [smart: false], - render: [hardbreaks: breaks, unsafe: true] - ) - |> MDEx.Document.run() - |> maybe_strip_email_autolinks(strip_email_autolinks) - |> MDEx.Document.update_nodes(MDEx.HtmlBlock, &strip_dangerous_raw_html/1) - |> MDEx.Document.update_nodes(MDEx.HtmlInline, &strip_dangerous_raw_html/1) - |> MDEx.to_html!() - |> compact_markdown_html(text) + {_status, ast, _messages} = EarmarkParser.as_ast(text, breaks: breaks, gfm: true) + + ast + |> maybe_strip_email_autolinks(text, Map.get(opts, :strip_email_autolinks, false)) + |> render_markdown_nodes(false) + |> IO.iodata_to_binary() + rescue + exception -> + Logger.warning("Markdown rendering failed in #{inspect(exception.__struct__)}") + markdown_fallback(text, Map.get(opts, :breaks, false)) end - defp compact_markdown_html(html, markdown) do - html - |> String.trim_trailing("\n") - |> String.replace(~r/>\n\s*<") - |> String.replace(~r/\n<\/code><\/pre>/, "") - |> String.replace(~r/(?)/, ~s()) - |> String.replace("
", "
") - |> unwrap_raw_html_paragraph(markdown) + defp markdown_fallback(text, breaks) do + text = text |> escape_html() |> IO.iodata_to_binary() |> maybe_render_markdown_breaks(breaks) + "

" <> text <> "

" end - defp normalize_markdown_quirks(text) do - String.replace(text, ~r/~~([^~\n]+)~~~/, "~~\\1~~\\\\~") + defp maybe_render_markdown_breaks(text, true), do: String.replace(text, ~r/\r?\n/, "
") + defp maybe_render_markdown_breaks(text, false), do: text + + defp maybe_strip_email_autolinks(ast, _text, false), do: ast + + defp maybe_strip_email_autolinks(ast, text, true) do + emails = + ~r/<([^ >]+@[^ >]+)>/ + |> Regex.scan(text, capture: :all_but_first) + |> Enum.map(fn [email] -> String.replace_prefix(email, "mailto:", "") end) + |> MapSet.new() + + strip_email_autolinks(ast, emails) end - defp unwrap_raw_html_paragraph(html, markdown) do - if inline_raw_html_only?(markdown) do - String.replace(html, ~r/\A

(.*)<\/p>\z/s, "\\1") + defp strip_email_autolinks(nodes, emails) when is_list(nodes) do + Enum.map(nodes, &strip_email_autolinks(&1, emails)) + end + + defp strip_email_autolinks({"a", attrs, [email], _meta} = node, emails) do + if List.keyfind(attrs, "href", 0) == {"href", "mailto:" <> email} and + MapSet.member?(emails, email) do + email else - html + node end end - defp inline_raw_html_only?(markdown) do - markdown = String.trim(markdown) - - String.match?( - markdown, - ~r/\A(?: