Untrusted data and secrets
Most injection bugs come from one mistake: text that arrived from outside the program ends up deciding what code, command, query, or path runs. Oska tracks that in the type system, so the mistake is a compile error with a stable code instead of a review comment. This page covers untrusted values, validators, sinks, and secrets.
Trust is a flag on text
Text from outside the program is an untrusted value, written Untrusted<String> in a signature. Plain String means trusted. Despite the spelling, Untrusted<String> is a flag the checker tracks and not a wrapper type (ADR 0006). At runtime it is an ordinary string.
Where untrusted text comes from:
- Anything read through a capability:
DirRead.read_to_string,FileRead.read_to_string,Stdin.read_line,Env.get. - Program inputs:
Args.get. - The
messageof anIoError,SqlError, orProcError.
The flow rule is one-way. Trusted text may go anywhere untrusted text is accepted. Untrusted text may never go where trusted String is required, and the checker reports E0300:
fn greet(name: String) -> String {
"hello"
}
fn main(io: Stdout, args: Args) -> Result<(), IoError> {
let who = args.get(1).unwrap_or("friend")
let message = greet(who)
io.println(message)
}
Text operations keep the flag. If any text input is untrusted, every text in the result is untrusted. Results that are not text are trusted, so inspecting untrusted text is free:
untrusted.trim(),untrusted.lines(), anduntrusted.split(",")are untrusted.untrusted.len(),untrusted.contains("@"), anduntrusted.parse_int()give a trustedInt,Bool, orOption<Int>.- Interpolating an untrusted value makes the whole string untrusted.
In your own code, a String parameter, field, or return type is trusted. Code that accepts raw input declares Untrusted<String>. User functions cannot be generic over trust.
Validators and trust assertions
A validator inspects an untrusted value and, only if it passes its checks, returns a trusted value of a validated type. The vouching step is the trust assertion .assume_trusted(), and it normally lives inside the validator.
struct Email { raw: String }
enum EmailError {
Missing,
}
impl Email {
fn parse(s: Untrusted<String>) -> Result<Email, EmailError> {
if !s.contains("@") {
return Err(EmailError.Missing)
}
Ok(Email(raw: s.assume_trusted()))
}
}
fn describe(email: Email) -> String {
"mail to {email.raw}"
}
Downstream code takes an Email, so it never sees raw text. Shadowing keeps the result tidy: let email = Email.parse(email)? replaces the raw value with the validated one.
Audit sites
Every .assume_trusted() and every secret .expose() is an audit site: a place where code deliberately weakens a guarantee. oskac check lists them all, with the byte span of the call, so a reviewer can find every one without reading the code.
fn vouch(name: Untrusted<String>) -> String {
name.assume_trusted()
}
fn vouch: pure
main.osk: audit: assume_trusted (bytes 50..71)
With --format=json they are in the audit array as {"kind":"assume_trusted","span":{...}} or {"kind":"expose",...}. A change that adds an audit site is the part of a diff most worth reading.
Sinks
A sink is an API where data leaves the program or changes how it behaves: SQL, processes, HTML, paths, network hosts, logs. Each sink separates two kinds of argument:
- A data position accepts untrusted values, because the sink treats it only as data. Examples are SQL parameters, process arguments, and escaped HTML text.
- A structural position rejects untrusted values, because it decides code, commands, or destinations. Examples are SQL query text, a program name,
TrustedHtml, the root of a directory or file capability, and a network host. A structural position is any parameter declaredString.
| Sink | Data position | Structural position |
|---|---|---|
std.sql | Db.query parameters | The query text, which must be an sql"..." literal, and connect's url |
std.proc | run's arguments | The program name |
std.html | text(...), which is escaped | TrustedHtml.new |
| Paths | Path.from | The directory given to restrict_to on Fs, FsRead, or FsWrite, and the file given to restrict_to_file |
| Network | Connection.send's body | NetConnect.connect's host |
| Output | Stdout.println, Stderr.println |
A sink literal such as sql"SELECT ..." provides text for a structural position. It can never interpolate, so there is no way to build a query from pieces. The tag must name an imported module, so sql"..." needs use std.sql.
use std.sql
fn user_name(db: sql.Db, id: Untrusted<String>) -> Result<Int, sql.SqlError> {
let rows = db.query(sql"SELECT name FROM users WHERE id = ?", [id])?
Ok(rows.len())
}
The user's id reaches the query only as a parameter. Passing any text where the query literal belongs is E0341:
use std.sql
fn user_name(db: sql.Db, id: Untrusted<String>) -> Result<Int, sql.SqlError> {
let rows = db.query("SELECT name FROM users WHERE id = ?", [id])?
Ok(rows.len())
}
A sink literal whose module is not imported is E0310, an unknown tag is E0311, and a module export written without its qualifier is E0340. std.html runs in this release. std.sql and std.proc check but do not run yet: oskac run refuses a program that imports one with E0371.
std.html escapes untrusted text in a data position. text(...) escapes &, <, >, ", and ', while TrustedHtml.new takes trusted markup, so putting untrusted text into markup unescaped needs an explicit, audited .assume_trusted():
use std.html
fn page(name: Untrusted<String>) -> html.Html {
let open = html.Html.raw(html.TrustedHtml.new("<p>"))
let close = html.Html.raw(html.TrustedHtml.new("</p>"))
open.concat(html.text(name)).concat(close)
}
fn main(io: Stdout, args: Args) -> Result<(), IoError> {
let name = args.get(1).unwrap_or("friend")
io.println(page(name).render())
}
<p><b>hi</b></p>
Paths
Path.from accepts untrusted text, because the protection for paths comes from attenuation and not from trust tracking. Attenuate to a directory capability before you read a path a user chose:
fn read_user_file(fs: DirRead, requested: Untrusted<String>) -> Result<Int, IoError> {
let text = fs.read_to_string(Path.from(requested))?
Ok(text.len())
}
fn main(io: Stdout, fs: Fs, args: Args) -> Result<(), IoError> {
let public = fs.restrict_to("public").read_only()
let name = args.get(1).unwrap_or("index.txt")
let size = read_user_file(public, name)?
io.println("{size} characters")?
Ok(())
}
The runtime rejects any path that escapes the directory capability's root. See capabilities.
Secrets
env.secret("API_KEY") returns a Secret<String>. In contrast, env.get returns an Untrusted<String>. A secret value is never displayed, logged, or interpolated:
- It has no
Display, so it cannot be printed or turned into text (E0352). - It cannot be interpolated, nor can any value that holds one, such as a
List<Secret<String>>or a struct with aSecretfield (E0350). - It cannot be compared with
==or!=, because that would leak timing information (E0351). Useconstant_time_eq(text), orconstant_time_eq_secret(other)for two secrets. dbgprints it as<redacted>.- The only way to read it is
.expose(), which returns trusted text and is an audit site.
fn check_key(key: Secret<String>, offered: Untrusted<String>) -> Bool {
key.constant_time_eq(offered)
}
fn main(io: Stdout, env: Env, args: Args) -> Result<(), IoError> {
let key = env.secret("SERVICE_KEY")
let offered = args.get(1).unwrap_or("")
if check_key(key, offered) {
io.println("welcome")?
}
Ok(())
}
If the variable is not set, env.secret panics with P0020 instead of continuing with an empty stand-in, so an empty password can never slip through.
Each mistake has a stable code:
fn leak(key: Secret<String>) -> String {
"Bearer {key}"
}
fn same(key: Secret<String>, offered: String) -> Bool {
key == offered
}
The fix attached to E0351 rewrites a == b to a.constant_time_eq(b), or to a.constant_time_eq_secret(b) when both sides are secrets.