// audit.mbt — Advisory security audit of a Content-Disposition value.
//
// Parsing answers "what does this header say?"; auditing answers "is there
// anything here that a careful HTTP client should look at twice?". The audit
// functions below inspect a parsed `ContentDisposition` (or a raw filename)
// and produce a list of `AuditIssue`s covering the well-known RFC 6266
// Section 7 concerns: ambiguous filenames, path separators, control
// characters, non-ASCII values without an RFC 8187 companion, duplicated
// parameters, risky file extensions and Windows reserved names.
//
// Auditing is advisory: an audit report never changes the parsed value and
// never raises. Every issue carries a severity so callers can implement a
// policy ("reject High issues", "log Warnings", ...).
///|
/// The severity of an audit issue. `Info` is informational, `Warning`
/// deserves attention, `High` should normally cause the download to be
/// treated as risky.
pub enum AuditSeverity {
Info
Warning
High
}
///|
/// The stable category of an audit issue.
pub enum AuditKind {
/// Neither `filename` nor `filename*` is present.
MissingFilename
/// The chosen filename is the empty string.
EmptyFilename
/// Both `filename` and `filename*` are present.
BothFilenameAndFilenameStar
/// Only the plain `filename` parameter is present (no RFC 8187 companion).
PlainFilenameOnly
/// `filename*` is present without a plain `filename` fallback.
FilenameStarWithoutFallback
/// The filename contains a `/` or `\` path separator.
PathSeparatorInFilename
/// The filename contains a control character (including CR/LF/NUL).
ControlCharacterInFilename
/// The plain `filename` contains non-ASCII bytes (legacy, unquoted form).
NonAsciiUnquotedFilename
/// A non-ASCII filename is not accompanied by an RFC 8187 `filename*`.
MissingFilenameStarForNonAscii
/// An extended value used an unsupported charset.
UnsupportedCharset
/// An extended value carried an invalid RFC 5646 language tag.
InvalidLanguageTag
/// A parameter name appeared more than once.
DuplicateParameter
/// A compatible-mode recovery was applied during parsing.
RecoveryApplied
/// The filename extension is in the executable/script risk list.
ExtensionRisk
/// The filename is a Windows reserved device name (CON, PRN, ...).
ReservedWindowsName
/// The filename ends in a dot or a space (silently trimmed by Windows).
TrailingDotOrSpace
/// The filename is long enough to be truncated by common file systems.
LongFilename
/// The disposition type is an extension type, not `inline`/`attachment`.
ExtensionDispositionType
}
///|
/// A single audit finding.
pub struct AuditIssue {
severity : AuditSeverity
kind : AuditKind
parameter : String
message : String
}
///|
/// The severity of this issue.
pub fn AuditIssue::severity(self : AuditIssue) -> AuditSeverity {
self.severity
}
///|
/// The stable kind of this issue.
pub fn AuditIssue::kind(self : AuditIssue) -> AuditKind {
self.kind
}
///|
/// The parameter the issue concerns (`filename`, `filename*`, or an empty
/// string for issues that concern the whole value).
pub fn AuditIssue::parameter(self : AuditIssue) -> String {
self.parameter
}
///|
/// A human-readable explanation of the issue.
pub fn AuditIssue::message(self : AuditIssue) -> String {
self.message
}
///|
/// The result of an audit: the issues found, in a stable order.
pub struct AuditReport {
issues : Array[AuditIssue]
}
///|
/// An empty audit report.
pub fn AuditReport::empty() -> AuditReport {
{ issues: [] }
}
///|
/// The issues found, in a stable order.
pub fn AuditReport::issues(self : AuditReport) -> Array[AuditIssue] {
self.issues
}
///|
/// The number of issues.
pub fn AuditReport::issue_count(self : AuditReport) -> Int {
self.issues.length()
}
///|
/// The number of issues at or above the given severity. Severity values are
/// obtained from `AuditIssue::severity()` (enum variants cannot be named in
/// expression position from consumer packages); `count_high` and
/// `count_at_least_warning` are the convenient forms.
pub fn AuditReport::count_severity(self : AuditReport, severity : AuditSeverity) -> Int {
let mut n = 0
for i in self.issues {
if severity_rank(i.severity()) >= severity_rank(severity) {
n = n + 1
}
}
n
}
///|
/// The number of High-severity issues.
pub fn AuditReport::count_high(self : AuditReport) -> Int {
self.count_severity_of_rank(2)
}
///|
/// The number of issues at Warning severity or above.
pub fn AuditReport::count_at_least_warning(self : AuditReport) -> Int {
self.count_severity_of_rank(1)
}
// Counts issues whose severity rank is at least `rank`.
fn AuditReport::count_severity_of_rank(self : AuditReport, rank : Int) -> Int {
let mut n = 0
for i in self.issues {
if severity_rank(i.severity()) >= rank {
n = n + 1
}
}
n
}
///|
/// A stable programmatic name for a severity.
pub fn AuditSeverity::to_string(self : AuditSeverity) -> String {
match self {
Info => "info"
Warning => "warning"
High => "high"
}
}
///|
/// A stable programmatic name for a kind, used by the CLI JSON output.
pub fn AuditKind::to_string(self : AuditKind) -> String {
match self {
MissingFilename => "missing-filename"
EmptyFilename => "empty-filename"
BothFilenameAndFilenameStar => "both-filename-and-filename-star"
PlainFilenameOnly => "plain-filename-only"
FilenameStarWithoutFallback => "filename-star-without-fallback"
PathSeparatorInFilename => "path-separator-in-filename"
ControlCharacterInFilename => "control-character-in-filename"
NonAsciiUnquotedFilename => "non-ascii-unquoted-filename"
MissingFilenameStarForNonAscii => "missing-filename-star-for-non-ascii"
UnsupportedCharset => "unsupported-charset"
InvalidLanguageTag => "invalid-language-tag"
DuplicateParameter => "duplicate-parameter"
RecoveryApplied => "recovery-applied"
ExtensionRisk => "extension-risk"
ReservedWindowsName => "reserved-windows-name"
TrailingDotOrSpace => "trailing-dot-or-space"
LongFilename => "long-filename"
ExtensionDispositionType => "extension-disposition-type"
}
}
///|
/// Audits a parsed `ContentDisposition` for the RFC 6266 Section 7
/// concerns. Never raises; the issues are advisory.
pub fn audit_content_disposition(cd : ContentDisposition) -> AuditReport {
let issues : Array[AuditIssue] = []
let star = cd.filename_star()
let plain = cd.filename()
match (star, plain) {
(None, None) => {
issues.push(
issue(AuditSeverity::Warning, AuditKind::MissingFilename, "", "no filename parameter is present"),
)
}
(Some(_), None) => {
issues.push(
issue(
AuditSeverity::Info,
AuditKind::FilenameStarWithoutFallback,
"filename*",
"filename* is present without a plain filename fallback",
),
)
}
(Some(_), Some(p)) => {
issues.push(
issue(
AuditSeverity::Info,
AuditKind::BothFilenameAndFilenameStar,
"filename",
"both filename and filename* are present; filename* takes precedence",
),
)
audit_filename_string(p, "filename", issues)
}
(None, Some(p)) => {
issues.push(
issue(
AuditSeverity::Info,
AuditKind::PlainFilenameOnly,
"filename",
"only the plain filename parameter is present",
),
)
audit_filename_string(p, "filename", issues)
}
}
// Extended value concerns.
match star {
Some(ev) => audit_extended_value(ev, issues)
None => ()
}
// Non-ASCII in the plain filename without an RFC 8187 companion.
match plain {
Some(p) => {
if has_non_ascii(p) && star is None {
issues.push(
issue(
AuditSeverity::Warning,
AuditKind::MissingFilenameStarForNonAscii,
"filename",
"non-ASCII filename without an RFC 8187 filename*",
),
)
}
}
None => ()
}
// Duplicate parameters.
let seen : Array[String] = []
for p in cd.parameters() {
let lower = p.name().to_lower()
let mut dup = false
for s in seen {
if s == lower {
dup = true
}
}
if dup {
issues.push(
issue(AuditSeverity::Warning, AuditKind::DuplicateParameter, p.name(), "duplicate parameter"),
)
} else {
seen.push(lower)
}
}
// Extension disposition type.
if cd.disposition_type().is_extension() {
issues.push(
issue(
AuditSeverity::Info,
AuditKind::ExtensionDispositionType,
"",
"disposition type is an extension type, not inline or attachment",
),
)
}
{
issues,
}
}
///|
/// Turns the diagnostics of a detailed parse (duplicate names and compatible
/// recoveries) into audit issues.
pub fn audit_disposition_parse(parse : DispositionParse) -> AuditReport {
let issues : Array[AuditIssue] = []
for name in parse.duplicates() {
issues.push(
issue(AuditSeverity::Warning, AuditKind::DuplicateParameter, name, "duplicate parameter"),
)
}
for recovery in parse.recoveries() {
issues.push(
issue(
AuditSeverity::Warning,
AuditKind::RecoveryApplied,
"",
"compatible recovery applied: \{recovery}",
),
)
}
{
issues,
}
}
///|
/// Audits a filename against a policy. The issues mirror the sanitizer's
/// concerns; this is the advisory side, `sanitize_filename` is the
/// corrective side.
pub fn audit_filename(name : String, policy : FilenamePolicy) -> AuditReport {
let issues : Array[AuditIssue] = []
audit_filename_string(name, "filename", issues)
if name.char_length() > policy.max_length() {
issues.push(
issue(
AuditSeverity::Info,
AuditKind::LongFilename,
"filename",
"filename exceeds the policy maximum length (\{policy.max_length()} code points)",
),
)
}
if policy.windows_reserved().is_enabled() && is_windows_reserved_name(name) {
issues.push(
issue(
AuditSeverity::High,
AuditKind::ReservedWindowsName,
"filename",
"filename is a Windows reserved device name",
),
)
}
{
issues,
}
}
///|
/// Audits a filename together with its declared media type. The media type
/// strengthens the extension-risk finding: an executable-looking extension
/// combined with an executable media type is High, on its own it is Warning.
pub fn audit_filename_with_media_type(
name : String,
media_type : String,
policy : FilenamePolicy
) -> AuditReport {
let report = audit_filename(name, policy)
let ext = filename_extension(name)
if risky_extension(ext) {
let severity = if executable_media_type(media_type) {
AuditSeverity::High
} else {
AuditSeverity::Warning
}
report.issues.push(
issue(
severity,
AuditKind::ExtensionRisk,
"filename",
"extension '.{ext}' is in the executable/script risk list (media type: \{media_type})",
),
)
}
report
}
///|
/// Audits a raw header value in one step: parse strictly, then combine the
/// parse diagnostics with the model audit. Errors when the value does not
/// parse.
pub fn audit_header(input : String) -> Result[AuditReport, DispositionError] {
let parse = match parse_content_disposition_detailed(input, ParseOptions::default()) {
Ok(p) => p
Err(e) => return Err(e)
}
let issues : Array[AuditIssue] = []
for i in audit_disposition_parse(parse).issues() {
issues.push(i)
}
for i in audit_content_disposition(parse.content_disposition()).issues() {
issues.push(i)
}
Ok({ issues: issues })
}
// Audits the plain string value of the `filename` parameter.
fn audit_filename_string(
value : String,
parameter : String,
issues : Array[AuditIssue]
) -> Unit {
if value == "" {
issues.push(
issue(AuditSeverity::Warning, AuditKind::EmptyFilename, parameter, "filename is empty"),
)
return
}
let bytes = @utf8.encode(value)
let mut saw_separator = false
let mut saw_control = false
for i = 0; i < bytes.length(); i = i + 1 {
if is_path_separator(bytes[i]) {
saw_separator = true
}
if is_control_byte(bytes[i]) {
saw_control = true
}
}
if saw_separator {
issues.push(
issue(
AuditSeverity::High,
AuditKind::PathSeparatorInFilename,
parameter,
"filename contains a path separator",
),
)
}
if saw_control {
issues.push(
issue(
AuditSeverity::High,
AuditKind::ControlCharacterInFilename,
parameter,
"filename contains a control character",
),
)
}
if has_non_ascii(value) {
issues.push(
issue(
AuditSeverity::Warning,
AuditKind::NonAsciiUnquotedFilename,
parameter,
"filename contains non-ASCII bytes",
),
)
}
if value.char_length() > 0 {
let last = value[value.char_length() - 1]
if last == '.' || last == ' ' {
issues.push(
issue(
AuditSeverity::Warning,
AuditKind::TrailingDotOrSpace,
parameter,
"filename ends in a dot or a space",
),
)
}
}
}
// Audits an RFC 8187 extended value for charset/language concerns.
fn audit_extended_value(ev : ExtendedValue, issues : Array[AuditIssue]) -> Unit {
if !is_supported_charset(ev.charset()) {
issues.push(
issue(
AuditSeverity::High,
AuditKind::UnsupportedCharset,
"filename*",
"unsupported charset: \{ev.charset()}",
),
)
}
match ev.language() {
Some(lang) => {
if !valid_language_tag(lang) {
issues.push(
issue(
AuditSeverity::Warning,
AuditKind::InvalidLanguageTag,
"filename*",
"invalid language tag: \{lang}",
),
)
}
}
None => ()
}
}
// Whether a string contains non-ASCII bytes.
fn has_non_ascii(value : String) -> Bool {
let bytes = @utf8.encode(value)
for i = 0; i < bytes.length(); i = i + 1 {
if is_obs_text(bytes[i]) {
return true
}
}
false
}
// The extension of a filename (after the last dot, lower-cased), or "".
fn filename_extension(name : String) -> String {
match last_dot_index(name) {
Some(i) => name[(i + 1):].to_owned().to_lower()
None => ""
}
}
// Whether an extension is in the executable/script risk list.
fn risky_extension(ext : String) -> Bool {
let risky = ["exe", "com", "bat", "cmd", "msi", "dll", "scr", "pif", "vbs", "js", "jse", "sh", "lnk"]
for r in risky {
if ext == r {
return true
}
}
false
}
// Whether a media type should be treated as executable/script-capable.
fn executable_media_type(media_type : String) -> Bool {
let mt = media_type.to_lower()
mt == "application/x-msdownload" || mt == "application/octet-stream" ||
mt.has_prefix("application/x-ms") || mt == "text/html" || mt == "application/javascript" ||
mt == "application/x-javascript" || mt.has_prefix("application/x-executable") ||
mt == "application/vnd.microsoft.portable-executable" || mt == "application/x-sh"
}
fn issue(
severity : AuditSeverity,
kind : AuditKind,
parameter : String,
message : String
) -> AuditIssue {
{ severity, kind, parameter, message }
}
// Numeric rank for severity filtering.
fn severity_rank(severity : AuditSeverity) -> Int {
match severity {
Info => 0
Warning => 1
High => 2
}
}