Class: Vangrail::Rails::Hidden

Inherits:
Vangrail::Rail show all
Defined in:
lib/vangrail/rails/hidden.rb

Overview

Reads the parts of a page a human never sees.

The largest measurement of indirect injection in the wild found roughly seven in ten instances sitting in non-rendered HTML: comments, meta tags, attributes, elements styled invisible. That is the natural place to put one. A visible paragraph telling an assistant to ignore its instructions is a paragraph the page's own readers will notice and report; the same sentence in an alt attribute is read by the model and by nobody else.

So this rail does not judge text. It extracts the spans a reader cannot see and hands each to the rails that already know what an injection looks like:

Rails::Hidden.new(rails: [Rails::InjectedInstructions.new,
                        Rails::Jailbreak.new])

Delegating rather than pattern-matching keeps one definition of "an injection" in the codebase, and keeps this class about where text was found rather than what it says. A hidden span with ordinary content in it passes: pages carry meta descriptions and alt text for good reasons, and a rail that objected to invisible text as such would reject most of the web. A child that rewrites a span (a key in a comment) replaces that span inside the page. The rest of the page is still the page.

Only useful where documents arrive as HTML. A retrieval step that converts to markdown before storing has usually dropped most of these carriers already, which is a reason to run this at the fetch boundary rather than a reason to skip it: what the converter drops silently is exactly what nobody is looking at.

Constant Summary collapse

CARRIERS =

Each entry pulls the readable part out of one carrier. Order is reporting order, so the named carrier is the first that matched rather than the last.

{
  'comment' => /<!--(.*?)-->/m,
  'meta' => /<meta\b[^>]*?\bcontent\s*=\s*["']([^"']{12,})["'][^>]*>/i,
  'alt_text' => /<[^>]+\balt\s*=\s*["']([^"']{12,})["'][^>]*>/i,
  'title_attribute' => /<[^>]+\btitle\s*=\s*["']([^"']{12,})["'][^>]*>/i,
  'aria_label' => /<[^>]+\baria-label\s*=\s*["']([^"']{12,})["'][^>]*>/i,
  'data_attribute' => /<[^>]+\bdata-[\w-]+\s*=\s*["']([^"']{12,})["'][^>]*>/i,
  'script' => /<script\b[^>]*>(.*?)<\/script>/mi,
  'template' => /<(?:template|noscript)\b[^>]*>(.*?)<\/(?:template|noscript)>/mi,
  # An element that is present, rendered, and invisible. The three ways
  # that is written in a page an attacker controls: a display or
  # visibility rule, a zero size, and text the colour of its background.
  'invisible_style' => /
    <[^>]*\bstyle\s*=\s*["'][^"']*
    (?:display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0|
       font-size\s*:\s*0|color\s*:\s*(?:\#f{3,6}|white|transparent))
    [^"']*["'][^>]*>(.*?)<\/
  /xmi,
  'hidden_attribute' => /<(\w+)\b[^>]*\bhidden\b[^>]*>(.*?)<\/\1>/mi,
  'hidden_input' => /
    <input\b(?=[^>]*\btype\s*=\s*["']?hidden["']?)
           [^>]*\bvalue\s*=\s*["']([^"']{12,})["']
           [^>]*>
  /xi,
  # Markdown carries two of its own: a link title, and image alt text.
  'link_title' => /\[[^\]]*\]\([^)\s]+\s+["']([^"']{12,})["']\)/,
  'image_alt' => /!\[([^\]]{12,})\]\(/,
}.freeze

Constants inherited from Vangrail::Rail

Vangrail::Rail::DEFAULT_SIDES, Vangrail::Rail::SIDES

Instance Attribute Summary collapse

Attributes inherited from Vangrail::Rail

#name, #sides

Instance Method Summary collapse

Methods inherited from Vangrail::Rail

#applies_to?, #call, #language_agnostic?, #placeholder?, #posture?, #quantifies?, #to_s, usable

Constructor Details

#initialize(rails:, carriers: CARRIERS, name: 'hidden', sides: [:context]) ⇒ Hidden

Returns a new instance of Hidden.



71
72
73
74
75
# File 'lib/vangrail/rails/hidden.rb', line 71

def initialize(rails:, carriers: CARRIERS, name: 'hidden', sides: [:context])
  super(name: name, sides: sides)
  @rails = Array(rails)
  @carriers = carriers
end

Instance Attribute Details

#carriersObject (readonly)

Returns the value of attribute carriers.



69
70
71
# File 'lib/vangrail/rails/hidden.rb', line 69

def carriers
  @carriers
end

#railsObject (readonly)

Returns the value of attribute rails.



69
70
71
# File 'lib/vangrail/rails/hidden.rb', line 69

def rails
  @rails
end

Instance Method Details

#cache_key(text, _context) ⇒ Object



81
82
83
# File 'lib/vangrail/rails/hidden.rb', line 81

def cache_key(text, _context)
  text if offline?
end

#decide(text, context) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/vangrail/rails/hidden.rb', line 85

def decide(text, context)
  rewritten = text.to_s
  modified = nil
  uncertain = nil

  blocked = catch(:blocked) do
    carriers.each do |carrier, pattern|
      rewritten = rewritten.gsub(pattern) do |full|
        span = captured_span(Regexp.last_match)
        next full if span.empty?

        current = span
        rails.each do |rail|
          result = rail.call(current, context)
          extra = ["hidden:#{carrier}"]
          if result.blocked?
            throw :blocked, wrapped_block(result, extra, carrier)
          end

          if result.modified?
            current = result.content.to_s
            modified = [result, extra, carrier]
            uncertain ||= result unless result.certain?
          elsif !result.certain?
            uncertain ||= result
          end
        end
        current == span ? full : full.sub(span) { current }
      end
    end
    nil
  end
  return blocked if blocked

  if modified
    result, extra, carrier = modified
    return modify(rewritten, categories: (result.categories || []) + extra,
                  reason: "#{result.reason} (hidden in #{carrier.tr('_', ' ')})",
                  certain: result.certain? && uncertain.nil?)
  end
  return unchecked(uncertain.reason) if uncertain

  pass
end

#offline?Boolean

Returns:

  • (Boolean)


77
78
79
# File 'lib/vangrail/rails/hidden.rb', line 77

def offline?
  rails.all?(&:offline?)
end

#spans(text) ⇒ Object

Every hidden span, labelled by where it came from. Public because an application that rejected a page wants to show what was in it.



132
133
134
135
136
137
138
139
140
# File 'lib/vangrail/rails/hidden.rb', line 132

def spans(text)
  body = text.to_s
  carriers.flat_map do |carrier, pattern|
    body.scan(pattern).filter_map do |match|
      span = Array(match).compact.max_by(&:length).to_s.strip
      [carrier, span] unless span.empty?
    end
  end
end