Class: Vangrail::Rails::Exfiltration

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

Overview

Strips outbound URLs an answer has no business emitting.

This is the rail for the attack that does not need the reader to do anything. A poisoned page tells the model to end its answer with a markdown image whose URL carries the conversation in a query parameter; the chat client renders markdown, so it fetches that URL by itself, and the data is gone before anybody has read a word. The same trick with a link needs one click, which a reader who trusts the assistant will give it. Every shipped assistant that rendered markdown had this, and the fix each of them landed on was the same: decide which hosts may be fetched, and refuse the rest.

So the rule here is an allowlist, and the default allowlist is empty, because a documentation assistant answering from a handbook has exactly one set of hosts worth linking to and the application knows what they are.

Rails::Exfiltration.new(allow_hosts: %w[docs.example.org example.org])

Images are stricter than links: an image is fetched without consent, so a host being allowlisted for links does not make it a place to auto-load from unless allow_images names it too.

It redacts rather than blocks. An answer with a bad link is a useful answer with one bad span in it, and throwing away the help teaches readers that the guardrail is the problem. The link text survives; the target does not.

Constant Summary collapse

PLACEHOLDER =

Kept out of prose because a bare marker in the middle of a sentence reads as an editing artefact, which is exactly what it is.

'[link removed]'
IMAGE_PLACEHOLDER =
'[image removed]'
IMAGE =

Markdown image, markdown link, bare HTML img/a, and anything with an explicit scheme that is not http(s). Autolinks in angle brackets count: some renderers fetch previews for them.

/!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?[^)]*\)/
/(?<!!)\[([^\]]*)\]\(\s*<?([^)\s>]+)>?[^)]*\)/
HTML_IMAGE =
/<img\b[^>]*?\bsrc\s*=\s*["']?([^"'>\s]+)[^>]*>/i
/<a\b[^>]*?\bhref\s*=\s*["']?([^"'>\s]+)[^>]*>(.*?)<\/a>/im
/<((?:https?|data|file|ftp):\/\/[^>\s]+)>/i
PAYLOAD =

A URL is suspicious on its own terms when it carries a payload: a long query string, percent-encoded text, or a base64 run. An allowlisted host with a hundred characters of query is still worth naming, because that is what the exfiltration looks like when the attacker knows the allowlist.

/[?#].{40,}/
ENCODED =
/(?:%[0-9A-Fa-f]{2}){6,}|[A-Za-z0-9+\/]{40,}={0,2}/

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?, #placeholder?, #to_s

Constructor Details

#initialize(allow_hosts: [], allow_images: nil, placeholder: PLACEHOLDER, max_query: 40, name: 'exfiltration', sides: [:output]) ⇒ Exfiltration

Returns a new instance of Exfiltration.



59
60
61
62
63
64
65
66
67
68
# File 'lib/vangrail/rails/exfiltration.rb', line 59

def initialize(allow_hosts: [], allow_images: nil, placeholder: PLACEHOLDER,
               max_query: 40, name: 'exfiltration', sides: [:output])
  super(name: name, sides: sides)
  @allow_hosts = normalise(allow_hosts)
  # nil means "the same hosts as links". An empty array means no images at
  # all, which is the safe reading of an application that never asked.
  @allow_images = allow_images.nil? ? @allow_hosts : normalise(allow_images)
  @placeholder = placeholder
  @max_query = max_query
end

Instance Attribute Details

#allow_hostsObject (readonly)

Returns the value of attribute allow_hosts.



57
58
59
# File 'lib/vangrail/rails/exfiltration.rb', line 57

def allow_hosts
  @allow_hosts
end

#allow_imagesObject (readonly)

Returns the value of attribute allow_images.



57
58
59
# File 'lib/vangrail/rails/exfiltration.rb', line 57

def allow_images
  @allow_images
end

#max_queryObject (readonly)

Returns the value of attribute max_query.



57
58
59
# File 'lib/vangrail/rails/exfiltration.rb', line 57

def max_query
  @max_query
end

#placeholderObject (readonly)

Returns the value of attribute placeholder.



57
58
59
# File 'lib/vangrail/rails/exfiltration.rb', line 57

def placeholder
  @placeholder
end

Instance Method Details

#allowed?(url, image: false) ⇒ Boolean

Whether this rail would leave the URL alone. Public because a caller rendering its own links wants the same answer without a Result.

Returns:

  • (Boolean)


90
91
92
93
94
95
96
97
98
# File 'lib/vangrail/rails/exfiltration.rb', line 90

def allowed?(url, image: false)
  host = host_of(url)
  return false if host.nil?

  list = image ? allow_images : allow_hosts
  return false unless list.any? { |h| host == h || host.end_with?(".#{h}") }

  !payload?(url)
end

#cache_key(text, _context) ⇒ Object



74
75
76
# File 'lib/vangrail/rails/exfiltration.rb', line 74

def cache_key(text, _context)
  text
end

#call(text, _context) ⇒ Object



78
79
80
81
82
83
84
85
86
# File 'lib/vangrail/rails/exfiltration.rb', line 78

def call(text, _context)
  body = text.to_s
  found = []
  cleaned = strip_all(body, found)
  return pass if found.empty?

  modify(cleaned, categories: found.uniq,
                  reason: "removed #{found.uniq.join(', ')}")
end

#offline?Boolean

Returns:

  • (Boolean)


70
71
72
# File 'lib/vangrail/rails/exfiltration.rb', line 70

def offline?
  true
end