Class: MaquinaStream::Sanitizer
- Inherits:
-
Object
- Object
- MaquinaStream::Sanitizer
- 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 =
hiddenearns 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
-
#config ⇒ Object
readonly
The Configuration whose URL keys this sanitizer reads.
Class Method Summary collapse
-
.call(html, config: MaquinaStream.config) ⇒ Object
Sanitizes one HTML string.
Instance Method Summary collapse
-
#call(html) ⇒ Object
Sanitizes
htmland returns the result as a String — a plain String, nothtml_safe: marking it is the caller's decision, and Renderer is the caller that makes it. -
#initialize(config: MaquinaStream.config) ⇒ Sanitizer
constructor
Builds a reusable sanitizer.
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 |