Skip to content

Taint tracking

Data that comes from outside the program — command-line arguments, file contents, stdin — is data an attacker controls. In Halis it carries a taint label in the type.

tainted[str] is not a runtime wrapper. On the native backend it is a compile-time property: the program cannot “forget” the label and still compile. If you strip it without an explicit sanitising step, the build fails.

Source What it yields
tainted_args() Every command-line argument, as list[tainted[str]]
read_file_tainted(path) The contents of a file that is not trusted
read_line() One line from stdin

A sink is a place where data leaves the program and can do harm: print, println, eprint, eprintln, read_file, write_file, file_exists, exit, net_lookup, proc_exec, and the net_tcp_* / net_udp_send_to / net_tls_get family.

A tainted value cannot reach a sink.

error: taint-sink violation: println argument 1 is tainted[str]
(tainted values must be sanitised before reaching a sink — use
sanitize_html / sanitize_path / sanitize_sql_identifier /
sanitize_sql_string / sanitize_command / sanitize_filename from
std.sanitize, or taint_unwrap() if you accept the risk)

For a network sink the message names the actual threat:

error: taint-sink violation: net_tcp_connect argument 1 (host) is tainted[str]
(a tainted host enables SSRF + DNS rebinding — use sanitize_command,
or taint_unwrap() if you accept the risk)

std.sanitize provides seven sanitiser families, each returning a clean str: sanitize_html, sanitize_html_attr, sanitize_path, sanitize_sql_identifier, sanitize_sql_string, sanitize_command and sanitize_filename. All of them are pure — they declare no effect — so they can be used anywhere.

import "std.sanitize"
fn main() -> int uses Args, IO {
let argv: list[tainted[str]] = tainted_args()
if argv.len() > 1 {
println("hello, " + sanitize_filename(argv.get(1)))
}
return 0
}

Routing on tainted data without untainting

Section titled “Routing on tainted data without untainting”

std.taint provides pure queries: taint_check_equals, taint_check_starts_with, taint_check_contains, taint_check_len and friends. They return a bool and keep the taint label on the other side.

if taint_check_equals(argv.get(1), "admin") {
println("admin route")
}

You can route on untrusted data without stripping the label. The label is only dropped when the data actually leaves the program. This is the difference between a controlled taint system and a machine that simply deletes tags.

  • Taint is a single label. tainted[str, Html] and tainted[str, Sql] are not implemented. Today, one sanitize call drops the label for every sink.
  • taint_unwrap() is a deliberate escape hatch. Using it immediately disables the taint system for that value. That is the user’s decision, not a toolchain bug.

See the quick reference for the full table of sources and sinks.