mirror of
https://github.com/ClaperCo/Claper.git
synced 2026-07-10 12:36:27 +02:00
* Fix critical security vulnerabilities Address 5 critical findings from security audit: - Sanitize custom embed HTML to prevent stored XSS (strip all non-iframe content) - Escape URLs in format_body/1 to prevent reflected XSS via post messages - Add authorization check to form export endpoint (IDOR fix) - Replace String.to_atom/1 on user input with explicit whitelists (8 locations) - Add IP-based rate limiting on authentication endpoints via Hammer * Start rate limiter before endpoint in supervision tree * Update CHANGELOG
159 lines
5.0 KiB
Elixir
159 lines
5.0 KiB
Elixir
defmodule Claper.Embeds.Embed do
|
|
use Ecto.Schema
|
|
import Ecto.Changeset
|
|
use Gettext, backend: ClaperWeb.Gettext
|
|
|
|
@type t :: %__MODULE__{
|
|
id: integer(),
|
|
title: String.t(),
|
|
content: String.t(),
|
|
provider: String.t(),
|
|
enabled: boolean(),
|
|
position: integer() | nil,
|
|
attendee_visibility: boolean() | nil,
|
|
presentation_file_id: integer() | nil,
|
|
inserted_at: NaiveDateTime.t(),
|
|
updated_at: NaiveDateTime.t()
|
|
}
|
|
|
|
@derive {Jason.Encoder, only: [:title, :content, :position, :attendee_visibility]}
|
|
schema "embeds" do
|
|
field :title, :string
|
|
field :content, :string
|
|
field :provider, :string
|
|
field :enabled, :boolean, default: true
|
|
field :position, :integer, default: 0
|
|
field :attendee_visibility, :boolean, default: false
|
|
|
|
belongs_to :presentation_file, Claper.Presentations.PresentationFile
|
|
|
|
timestamps()
|
|
end
|
|
|
|
@doc false
|
|
def changeset(embed, attrs \\ %{}) do
|
|
embed
|
|
|> cast(attrs, [
|
|
:enabled,
|
|
:title,
|
|
:content,
|
|
:provider,
|
|
:presentation_file_id,
|
|
:position,
|
|
:attendee_visibility
|
|
])
|
|
|> validate_required([
|
|
:title,
|
|
:content,
|
|
:provider,
|
|
:presentation_file_id,
|
|
:position,
|
|
:attendee_visibility
|
|
])
|
|
|> validate_inclusion(:provider, ["youtube", "vimeo", "canva", "googleslides", "custom"])
|
|
|> validate_provider_url()
|
|
end
|
|
|
|
defp validate_provider_url(changeset) do
|
|
case get_field(changeset, :provider) do
|
|
"youtube" ->
|
|
changeset
|
|
|> validate_format(:content, ~r/^https?:\/\/.+$/,
|
|
message: gettext("Please enter a valid link starting with http:// or https://")
|
|
)
|
|
|> validate_format(:content, ~r/(youtu\.be)|(youtube\.com)/,
|
|
message: gettext("Please enter a valid %{provider} link", provider: "YouTube")
|
|
)
|
|
|
|
"canva" ->
|
|
changeset
|
|
|> validate_format(:content, ~r/^https?:\/\/.+$/,
|
|
message: gettext("Please enter a valid link starting with http:// or https://")
|
|
)
|
|
|> validate_format(:content, ~r/canva\.com/,
|
|
message: gettext("Please enter a valid %{provider} link", provider: "Canva")
|
|
)
|
|
|
|
"googleslides" ->
|
|
changeset
|
|
|> validate_format(:content, ~r/^https?:\/\/.+$/,
|
|
message: gettext("Please enter a valid link starting with http:// or https://")
|
|
)
|
|
|> validate_format(:content, ~r/google\.com/,
|
|
message: gettext("Please enter a valid %{provider} link", provider: "Google Slides")
|
|
)
|
|
|
|
"vimeo" ->
|
|
changeset
|
|
|> validate_format(:content, ~r/^https?:\/\/.+$/,
|
|
message: gettext("Please enter a valid link starting with http:// or https://")
|
|
)
|
|
|> validate_format(:content, ~r/vimeo\.com/,
|
|
message: gettext("Please enter a valid %{provider} link", provider: "Vimeo")
|
|
)
|
|
|
|
"custom" ->
|
|
changeset
|
|
|> validate_format(:content, ~r/<iframe.*?<\/iframe>/s,
|
|
message: gettext("Please enter valid HTML content with an iframe tag")
|
|
)
|
|
|> sanitize_custom_embed()
|
|
|
|
_ ->
|
|
changeset
|
|
end
|
|
end
|
|
|
|
@allowed_iframe_attrs ~w(src width height frameborder allow allowfullscreen style title loading referrerpolicy sandbox)
|
|
|
|
defp sanitize_custom_embed(%{valid?: false} = changeset), do: changeset
|
|
|
|
defp sanitize_custom_embed(changeset) do
|
|
content = get_field(changeset, :content)
|
|
|
|
case Regex.run(~r/<iframe\s[^>]*?(?:\/>|>[\s\S]*?<\/iframe>)/i, content) do
|
|
[iframe_tag] ->
|
|
put_change(changeset, :content, sanitize_iframe_tag(iframe_tag))
|
|
|
|
_ ->
|
|
add_error(
|
|
changeset,
|
|
:content,
|
|
gettext("Please enter valid HTML content with an iframe tag")
|
|
)
|
|
end
|
|
end
|
|
|
|
@allowed_boolean_attrs ~w(allowfullscreen sandbox)
|
|
|
|
defp sanitize_iframe_tag(iframe_tag) do
|
|
# Extract key="value" attributes
|
|
value_attrs =
|
|
Regex.scan(~r/([\w-]+)\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i, iframe_tag)
|
|
|> Enum.filter(fn [_, name | _] -> String.downcase(name) in @allowed_iframe_attrs end)
|
|
|> Enum.reject(fn [_, name, value | _] ->
|
|
String.downcase(name) == "src" and String.trim(value) =~ ~r/^javascript:/i
|
|
end)
|
|
|> Enum.map(fn [_, name, value | _rest] ->
|
|
~s(#{String.downcase(name)}="#{String.replace(value, "\"", """)}")
|
|
end)
|
|
|
|
# Extract standalone boolean attributes (e.g., allowfullscreen)
|
|
value_attr_names =
|
|
Regex.scan(~r/([\w-]+)\s*=/i, iframe_tag)
|
|
|> Enum.map(fn [_, name] -> String.downcase(name) end)
|
|
|> MapSet.new()
|
|
|
|
boolean_attrs =
|
|
Regex.scan(~r/\s([\w-]+)(?=[\s>\/])/i, iframe_tag)
|
|
|> Enum.map(fn [_, name] -> String.downcase(name) end)
|
|
|> Enum.filter(&(&1 in @allowed_boolean_attrs))
|
|
|> Enum.reject(&MapSet.member?(value_attr_names, &1))
|
|
|> Enum.uniq()
|
|
|
|
all_attrs = Enum.join(value_attrs ++ boolean_attrs, " ")
|
|
|
|
if all_attrs == "", do: "<iframe></iframe>", else: "<iframe #{all_attrs}></iframe>"
|
|
end
|
|
end
|