Class: Vangrail::Rails::Exfiltration
- Inherits:
-
Vangrail::Rail
- Object
- Vangrail::Rail
- Vangrail::Rails::Exfiltration
- 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>]+)>?[^)]*\)/- LINK =
/(?<!!)\[([^\]]*)\]\(\s*<?([^)\s>]+)>?[^)]*\)/- HTML_IMAGE =
/<img\b[^>]*?\bsrc\s*=\s*["']?([^"'>\s]+)[^>]*>/i- HTML_LINK =
/<a\b[^>]*?\bhref\s*=\s*["']?([^"'>\s]+)[^>]*>(.*?)<\/a>/im- AUTOLINK =
/<((?: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}/
Instance Attribute Summary collapse
-
#allow_hosts ⇒ Object
readonly
Returns the value of attribute allow_hosts.
-
#allow_images ⇒ Object
readonly
Returns the value of attribute allow_images.
-
#max_query ⇒ Object
readonly
Returns the value of attribute max_query.
-
#placeholder ⇒ Object
readonly
Returns the value of attribute placeholder.
Instance Method Summary collapse
-
#allowed?(url, image: false) ⇒ Boolean
Whether this rail would leave the URL alone.
- #cache_key(text, _context) ⇒ Object
- #call(text, _context) ⇒ Object
-
#initialize(allow_hosts: [], allow_images: nil, placeholder: PLACEHOLDER, max_query: 40, name: 'exfiltration', sides: [:output]) ⇒ Exfiltration
constructor
A new instance of Exfiltration.
- #language_agnostic? ⇒ Boolean
- #offline? ⇒ Boolean
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_hosts ⇒ Object (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_images ⇒ Object (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_query ⇒ Object (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 |
#placeholder ⇒ Object (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.
94 95 96 97 98 99 100 101 102 |
# File 'lib/vangrail/rails/exfiltration.rb', line 94 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
78 79 80 |
# File 'lib/vangrail/rails/exfiltration.rb', line 78 def cache_key(text, _context) text end |
#call(text, _context) ⇒ Object
82 83 84 85 86 87 88 89 90 |
# File 'lib/vangrail/rails/exfiltration.rb', line 82 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 |
#language_agnostic? ⇒ Boolean
74 75 76 |
# File 'lib/vangrail/rails/exfiltration.rb', line 74 def language_agnostic? true end |
#offline? ⇒ Boolean
70 71 72 |
# File 'lib/vangrail/rails/exfiltration.rb', line 70 def offline? true end |