///|
fn sanitized_string_set(values : Array[String]) -> Set[String] {
let set : Set[String] = Set::default()
for value in values {
let normalized = @syn.lower_ascii(value[:].trim())
if normalized != "" {
set.add(normalized)
}
}
set
}
///|
fn string_array_contains(values : Array[String], needle : String) -> Bool {
for value in values {
if value == needle {
return true
}
}
false
}
///|
/// Append `value` to `values` if it is non-empty and absent.
///
/// Existing order is preserved, making this suitable for normalized allowlists
/// and token merges where duplicate entries should be ignored.
pub fn push_unique_string(values : Array[String], value : String) -> Unit {
if value != "" && !string_array_contains(values, value) {
values.push(value)
}
}
///|
fn sanitized_string_list(values : Array[String]) -> Array[String] {
let out : Array[String] = []
for value in values {
push_unique_string(out, @syn.lower_ascii(value[:].trim()))
}
out
}
///|
fn sanitize_allowed_attributes(
attrs : Map[String, Array[String]],
) -> Map[String, Set[String]] {
let normalized : Map[String, Set[String]] = {}
for tag, values in attrs {
let tag_name = @syn.lower_ascii(tag[:].trim())
if tag_name != "" {
normalized[tag_name] = sanitized_string_set(values)
}
}
normalized
}
///|
/// Construct a URL filter from a callback.
pub fn UrlFilter::new(
callback : (String, String, String) -> String?,
) -> UrlFilter {
{ callback, }
}
///|
/// Construct a URL proxy descriptor.
pub fn UrlProxy::new(url : StringView, param? : String = "url") -> UrlProxy {
{ url: url.to_owned(), param }
}
///|
/// Construct a per-attribute URL validation rule.
///
/// Schemes and hosts are normalized to lowercase. `resolve_protocol_relative`
/// rewrites protocol-relative URLs such as `//example.com` before validation.
pub fn UrlRule::new(
allowed_schemes? : Array[String] = [],
allowed_hosts? : Array[String] = [],
allow_fragment? : Bool = true,
resolve_protocol_relative? : String? = Some("https"),
handling? : UrlHandling,
allow_relative? : Bool,
proxy? : UrlProxy,
) -> UrlRule {
{
allow_fragment,
resolve_protocol_relative: resolve_protocol_relative.map(fn(scheme) {
@syn.lower_ascii(scheme[:].trim())
}),
allowed_schemes: sanitized_string_set(allowed_schemes),
allowed_hosts: sanitized_string_set(allowed_hosts),
handling,
allow_relative,
proxy,
}
}
///|
/// Construct a URL policy rule for one tag and attribute.
pub fn UrlPolicyRule::new(
tag : StringView,
attr : StringView,
rule : UrlRule,
) -> UrlPolicyRule {
{
tag: @syn.lower_ascii(tag.trim()),
attr: @syn.lower_ascii(attr.trim()),
rule,
}
}
///|
fn url_policy_rule_key(tag : String, attr : String) -> String {
tag + "\u{0000}" + attr
}
///|
fn sanitize_url_policy_rules(
rules : Array[UrlPolicyRule],
) -> Map[String, UrlRule] {
let out : Map[String, UrlRule] = {}
for rule in rules {
if rule.tag != "" && rule.attr != "" {
out[url_policy_rule_key(rule.tag, rule.attr)] = rule.rule
}
}
out
}
///|
/// Construct a URL policy.
///
/// `allow_rules` are exact tag/attribute bindings. `url_filter` runs before
/// rule validation and can rewrite or drop the URL value.
pub fn UrlPolicy::new(
default_handling? : UrlHandling = UrlAllow,
default_allow_relative? : Bool = true,
allow_rules? : Array[UrlPolicyRule] = [],
proxy? : UrlProxy,
url_filter? : UrlFilter,
) -> UrlPolicy {
{
default_handling,
default_allow_relative,
allow_rules: sanitize_url_policy_rules(allow_rules),
proxy,
url_filter,
}
}
///|
fn default_url_policy() -> UrlPolicy {
UrlPolicy::new(default_handling=UrlStrip, allow_rules=[
UrlPolicyRule::new(
"a",
"href",
UrlRule::new(
allowed_schemes=["http", "https", "mailto", "tel"],
handling=UrlAllow,
),
),
UrlPolicyRule::new(
"img",
"src",
UrlRule::new(
allow_fragment=false,
resolve_protocol_relative=None,
handling=UrlAllow,
),
),
])
}
///|
/// Construct a DOM sanitization policy.
///
/// Tag and attribute names are normalized to lowercase. By default comments,
/// doctypes, foreign namespaces, script/style content, and invisible Unicode
/// controls are removed. Unsafe findings are stripped unless `unsafe_handling`
/// is set to `Raise` or `Collect`.
pub fn SanitizationPolicy::new(
allowed_tags : Array[String],
allowed_attributes? : Map[String, Array[String]] = {},
url_policy? : UrlPolicy,
drop_comments? : Bool = true,
drop_doctype? : Bool = true,
drop_foreign_namespaces? : Bool = true,
drop_content_tags? : Array[String] = ["script", "style"],
disallowed_tag_handling? : DisallowedTagHandling = Unwrap,
force_link_rel? : Array[String] = [],
allowed_css_properties? : Array[String] = [],
strip_invisible_unicode? : Bool = true,
selector_limits? : @sel.SelectorLimits,
unsafe_handling? : UnsafeHandling = Strip,
) -> SanitizationPolicy {
{
allowed_tags: sanitized_string_set(allowed_tags),
allowed_attributes: sanitize_allowed_attributes(allowed_attributes),
url_policy: url_policy.unwrap_or(default_url_policy()),
drop_comments,
drop_doctype,
drop_foreign_namespaces,
drop_content_tags: sanitized_string_set(drop_content_tags),
disallowed_tag_handling,
force_link_rel: sanitized_string_list(force_link_rel),
allowed_css_properties: sanitized_string_set(allowed_css_properties),
strip_invisible_unicode,
selector_limits: selector_limits.unwrap_or(@sel.SelectorLimits::new()),
unsafe_handling,
security_errors: [],
}
}
///|
/// Clear unsafe findings accumulated in `Collect` mode.
pub fn SanitizationPolicy::reset_collected_security_errors(
self : SanitizationPolicy,
) -> Unit {
self.security_errors.clear()
}
///|
/// Return a copy of unsafe findings accumulated in `Collect` mode.
pub fn SanitizationPolicy::collected_security_errors(
self : SanitizationPolicy,
) -> Array[@core.ParseError] {
self.security_errors.copy()
}
///|
/// Return the selector limits used by this policy.
///
/// Sanitizer transforms use these limits when selector-based hooks are
/// evaluated during sanitization.
pub fn SanitizationPolicy::selector_limits(
self : SanitizationPolicy,
) -> @sel.SelectorLimits {
self.selector_limits
}
///|
fn sanitize_allowed_attributes_to_arrays(
attrs : Map[String, Set[String]],
) -> Map[String, Array[String]] {
let out : Map[String, Array[String]] = {}
for tag, values in attrs {
out[tag] = values.to_array()
}
out
}
///|
/// Return a copy of this policy with additional allowed tag names.
///
/// Extra tags are trimmed, ASCII-lowercased, deduplicated, and merged with the
/// existing allowlist. Other policy settings, including URL rules and unsafe
/// handling, are preserved.
pub fn SanitizationPolicy::with_extra_allowed_tags(
self : SanitizationPolicy,
extra_tags : Array[String],
) -> SanitizationPolicy {
let tags = self.allowed_tags.to_array()
for tag in extra_tags {
push_unique_string(tags, @syn.lower_ascii(tag[:].trim()))
}
SanitizationPolicy::new(
tags,
allowed_attributes=sanitize_allowed_attributes_to_arrays(
self.allowed_attributes,
),
url_policy=self.url_policy,
drop_comments=self.drop_comments,
drop_doctype=self.drop_doctype,
drop_foreign_namespaces=self.drop_foreign_namespaces,
drop_content_tags=self.drop_content_tags.to_array(),
disallowed_tag_handling=self.disallowed_tag_handling,
force_link_rel=self.force_link_rel.copy(),
allowed_css_properties=self.allowed_css_properties.to_array(),
strip_invisible_unicode=self.strip_invisible_unicode,
selector_limits=self.selector_limits,
unsafe_handling=self.unsafe_handling,
)
}
///|
/// Return whether this policy has an exact URL rule for a tag and attribute.
///
/// `tag_name` and `attr_name` should be normalized lowercase names. The default
/// URL handling is not considered a rule; this checks only explicit
/// tag/attribute bindings.
pub fn SanitizationPolicy::has_url_rule(
self : SanitizationPolicy,
tag_name : String,
attr_name : String,
) -> Bool {
self.url_policy.lookup_rule(tag_name, attr_name) is Some(_)
}
///|
fn SanitizationPolicy::handle_unsafe(
self : SanitizationPolicy,
message : String,
) -> Unit raise @core.HtmlError {
match self.unsafe_handling {
Strip => ()
Raise => raise UnsafeHtml(message)
Collect =>
self.security_errors.push(
@core.ParseError::new("unsafe-html", category="security", message~),
)
}
}
///|
fn default_allowed_tags() -> Array[String] {
[
"p", "br", "div", "span", "blockquote", "pre", "code", "h1", "h2", "h3", "h4",
"h5", "h6", "ul", "ol", "li", "table", "caption", "thead", "tbody", "tfoot",
"tr", "th", "td", "b", "strong", "i", "em", "u", "s", "sub", "sup", "small",
"mark", "hr", "a", "img",
]
}
///|
fn default_allowed_attributes() -> Map[String, Array[String]] {
{
"*": ["class", "id", "title", "lang", "dir"],
"a": ["href", "title"],
"img": ["src", "alt", "title", "width", "height", "loading", "decoding"],
"th": ["colspan", "rowspan"],
"td": ["colspan", "rowspan"],
}
}
///|
/// Return the default fragment sanitizer policy.
pub fn default_sanitization_policy() -> SanitizationPolicy {
SanitizationPolicy::new(
default_allowed_tags(),
allowed_attributes=default_allowed_attributes(),
)
}
///|
/// Return the default document sanitizer policy.
///
/// This extends the fragment policy with document shell tags and preserves the
/// doctype.
pub fn default_document_sanitization_policy() -> SanitizationPolicy {
let tags = default_allowed_tags()
tags.push("html")
tags.push("head")
tags.push("body")
tags.push("title")
SanitizationPolicy::new(
tags,
allowed_attributes=default_allowed_attributes(),
drop_doctype=false,
)
}
///|
/// Return the conservative text-style CSS property allowlist.
pub fn css_preset_text() -> Array[String] {
[
"background-color", "color", "font-size", "font-style", "font-weight", "letter-spacing",
"line-height", "text-align", "text-decoration", "text-transform", "white-space",
"word-break", "word-spacing", "word-wrap",
]
}