Class: MaquinaStream::Sanitizer

Inherits:
Object
  • Object
show all
Defined in:
lib/maquina_stream/sanitizer.rb

Overview

Allowlist plus URL hardening. The last pass before output, and it runs unconditionally.

MaquinaStream::Sanitizer.call(html, config: MaquinaStream.config) # => String

The input is model output: hostile, prompt-injectable, and never trusted because an earlier stage already looked at it. Nothing here is a cleanup pass — an element or an attribute survives only by being named, and a URL survives only by being re-parsed and re-checked. Everything else is dropped, not escaped and kept.

Renderer calls this itself, last, on every render. A host only calls it directly when it produces HTML of its own that a model had a hand in.

Five configuration keys steer it — default_origin, allowed_protocols, allowed_link_prefixes, allowed_image_prefixes and allow_data_images. Nothing else about it is configurable: the element and attribute allowlists are constants, and widening one means editing this file with the regression suite in front of you.

It is not the last line of defence either. Renderer output is sanitized again client-side before it reaches the DOM, because the payload came from a model and model output is prompt-injectable.

See docs/security.md for what survives, what is dropped, and the known holes.

Constant Summary collapse

ALLOWED_ELEMENTS =

Elements that survive: rendered markdown, plus the wrappers the post-pass adds around it.

%w[
  p div span br hr
  h1 h2 h3 h4 h5 h6
  ul ol li dl dt dd
  table thead tbody tfoot tr td th caption colgroup col
  pre code kbd samp var
  blockquote figure figcaption details summary section article aside
  a img button
  em strong b i u s del ins mark small sub sup q abbr dfn cite time wbr
  input
].to_set.freeze
DROP_WITH_CONTENT =

Removed with everything inside them. Unwrapping these would smuggle their contents back into the document: script text, CSS, or a foreign-content (SVG/MathML) subtree whose parsing rules are not HTML's.

%w[
  script style svg math template noscript iframe frame frameset object
  embed applet param form select option optgroup textarea label
  fieldset legend base link meta head title html body audio video source
  track canvas map area portal dialog marquee plaintext xmp listing
].to_set.freeze
GLOBAL_ATTRIBUTES =

hidden earns its place: the raw-source carrier is a hidden

, and a
carrier that loses its hidden attribute renders every code block twice.

%w[id class title lang dir role translate hidden].to_set.freeze
ELEMENT_ATTRIBUTES =
{
  "a" => %w[href target rel hreflang type],
  # The component controls are buttons, so button cannot be dropped
  # wholesale. It is allowed with a deliberately short attribute list: no
  # `name`, `value`, `form`, `formaction` or `formmethod`, so an injected
  # button cannot submit anything, and `type` is forced to "button" below.
  # Behaviour still comes only from `data-action`, which is already
  # restricted to the ms- namespace, and every ms- controller treats its own
  # values as untrusted.
  "button" => %w[type disabled aria-pressed aria-expanded aria-controls],
  "img" => %w[src alt width height loading decoding],
  "ol" => %w[start reversed type],
  "li" => %w[value],
  "td" => %w[colspan rowspan align valign headers scope],
  "th" => %w[colspan rowspan align valign headers scope abbr],
  "col" => %w[span align],
  "colgroup" => %w[span align],
  "table" => %w[align],
  "input" => %w[type checked disabled],
  "time" => %w[datetime],
  "details" => %w[open]
}.transform_values { |names| names.to_set.freeze }.freeze
FORBIDDEN_ATTRIBUTES =

The allowlist already excludes every one of these. Naming them keeps the intent across refactors and gives the regression suite something explicit to assert on.

%w[
  srcdoc formaction xlink:href xlink:show xlink:actuate xml:base
  style action background dynsrc lowsrc ping http-equiv srcset usemap
  accesskey contenteditable name
].to_set.freeze
STATIC_DATA_ATTRIBUTES =

Non-Stimulus data hooks our own partials emit.

%w[
  data-component data-variant data-size data-slot data-state data-side
  data-orientation data-controller data-action data-turbo-permanent
  data-turbo-temporary
].to_set.freeze
DATA_ATTRIBUTE_SHAPE =
/\Adata-[a-z0-9]+(?:-[a-z0-9]+)*\z/
COMPONENT_PART_ATTRIBUTE =

Component internals: data-code-block-part, data-shimmer-part and friends. They carry no behaviour, only styling hooks for a component's own parts.

/\Adata-[a-z0-9]+(?:-[a-z0-9]+)*-part\z/
ARIA_ATTRIBUTE_SHAPE =
/\Aaria-[a-z]+\z/
CONTROLLER_IDENTIFIER =

Only our own controller namespace. A host or third-party identifier arriving inside model output has no business being instantiated.

/\Ams-[a-z0-9]+(?:-[a-z0-9]+)*\z/
ACTION_DESCRIPTOR =
%r{
  \A
  (?:[a-zA-Z0-9:.-]+(?:@[a-z]+)?->)?
  ms-[a-z0-9-]+\#[a-zA-Z_][a-zA-Z0-9_]*
  (?::[a-z]+)*
  \z
}x
DANGEROUS_SCHEMES =

Rejected however they are spelled, including after the decoding a browser would do on our behalf.

%w[
  javascript livescript vbscript jscript mocha data file blob about jar
  view-source chrome chrome-extension resource feed ms-its
].to_set.freeze
URL_NOISE =

Control characters, and the whitespace a browser strips before it reads the scheme. Removing them first means the scheme we test is the scheme the browser will act on.

/[\u0000-\u0020\u007F-\u00A0\u1680\u180E\u2000-\u200F\u2028\u2029\u202F\u205F\u3000\uFEFF]/
DATA_IMAGE =
%r{
  \Adata:image/(?:png|jpe?g|gif|webp|avif|bmp|x-icon|vnd\.microsoft\.icon)
  ;base64,[A-Za-z0-9+/=]+\z
}xi

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config: MaquinaStream.config) ⇒ Sanitizer

Builds a reusable sanitizer. A nil config: falls back to the global one rather than failing later, deep in a URL check.



150
151
152
# File 'lib/maquina_stream/sanitizer.rb', line 150

def initialize(config: MaquinaStream.config)
  @config = config || MaquinaStream.config
end

Instance Attribute Details

#config ⇒ Object (readonly)

The Configuration whose URL keys this sanitizer reads.



146
147
148
# File 'lib/maquina_stream/sanitizer.rb', line 146

def config
  @config
end

Class Method Details

.call(html, config: MaquinaStream.config) ⇒ Object

Sanitizes one HTML string. The usual entry point.



140
141
142
# File 'lib/maquina_stream/sanitizer.rb', line 140

def call(html, config: MaquinaStream.config)
  new(config: config).call(html)
end

Instance Method Details

#call(html) ⇒ Object

Sanitizes html and returns the result as a String — a plain String, not html_safe: marking it is the caller's decision, and Renderer is the caller that makes it. Empty in, empty out.



157
158
159
160
161
162
163
164
165
166
# File 'lib/maquina_stream/sanitizer.rb', line 157

def call(html)
  return "" if html.nil?

  source = html.to_s
  return "" if source.empty?

  fragment = parse(source)
  scrub_children(fragment)
  fragment.to_html
end