04 · Security Best Practices¶
Databases covered SQL injection. This module covers the rest of the security fundamentals every production Dart service needs: hashing secrets properly, using a cryptographically secure random source, and validating file paths built from user input.
The trap: plain hashing is not password storage¶
sha256 alone is fast and deterministic — exactly the two properties you
don't want for password storage. Fast means an attacker with a stolen hash
database can try billions of guesses per second; deterministic means two
users with the same password get the identical hash, leaking that fact.
import 'dart:convert';
import 'package:crypto/crypto.dart';
String hashPasswordInsecure(String password) {
return sha256.convert(utf8.encode(password)).toString();
}
void main() {
const password = 'correct horse battery staple';
print('Insecure hash: ${hashPasswordInsecure(password)}');
print('Insecure hash again (identical!): ${hashPasswordInsecure(password)}');
}
// Insecure hash: c4bbcb1fbec99d65bf59d85c8cb62ee2db963f0fe106f483d9afa73bd4e39a8a
// Insecure hash again (identical!): c4bbcb1fbec99d65bf59d85c8cb62ee2db963f0fe106f483d9afa73bd4e39a8a
Real systems add a per-user random salt (so identical passwords hash
differently) and use a deliberately slow algorithm (bcrypt, scrypt,
Argon2 — not covered by package:crypto, which only provides fast general-
purpose hashes) so brute-forcing is expensive even per-guess. Salting alone,
shown here with plain SHA-256, only fixes the "identical passwords, identical
hashes" half of the problem — it's a stepping stone to understanding the
real fix, not a production-ready scheme by itself.
import 'dart:math';
String generateSalt() {
final random = Random.secure(); // cryptographically secure, unlike Random()
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
return base64Url.encode(bytes);
}
String hashPasswordSalted(String password, String salt) {
return sha256.convert(utf8.encode('$salt:$password')).toString();
}
void main() {
const password = 'correct horse battery staple';
final saltA = generateSalt();
final saltB = generateSalt();
print('Salted hash A: ${hashPasswordSalted(password, saltA)}');
print('Salted hash B (different salt, same password): ${hashPasswordSalted(password, saltB)}');
print('Salts differ: ${saltA != saltB}');
}
// Salted hash A: 3bc54580d987720268eb0ab16f230c334808755495cdc8ca264f50b54337c84b
// Salted hash B (different salt, same password): fb1c14c29b7df168182434e8f66555f74533b44924ff18e560c7c1128858a651
// Salts differ: true
The trap: Random() is not cryptographically secure¶
Plain Random() is a fast pseudo-random generator meant for things like
game logic and shuffling — its output is predictable enough that an
attacker who sees a few outputs can potentially reconstruct future ones.
Anything security-sensitive (salts, tokens, session ids, password reset
codes) must use Random.secure(), which draws from the OS's
cryptographically secure entropy source. The only visible difference in the
API is the constructor name — which makes reaching for the wrong one an
easy, silent mistake.
Path traversal: never trust a user-supplied path¶
Serving files based on user input (a filename in a URL, an upload path)
without validation lets ../ sequences escape the directory you meant to
restrict access to.
import 'dart:io';
Future<void> main() async {
// ... publicDir contains hello.txt; a sibling secret.txt sits outside it.
// DANGER: joining user input directly onto a base path lets '..' escape it.
String unsafeResolve(String base, String userPath) => '$base/$userPath';
final userInput = '../secret.txt';
final resolvedUnsafe = File(unsafeResolve(publicDir.path, userInput)).absolute.path;
print('Unsafe read: ${await File(resolvedUnsafe).readAsString()}');
}
// Unsafe read: TOP SECRET
The fix: normalize the resulting path and explicitly check that it's still inside the allowed root before touching the filesystem.
String? safeResolve(String base, String userPath) {
final baseAbs = Directory(base).absolute.path;
final candidate = Uri.file('$base/$userPath').normalizePath().toFilePath();
if (!candidate.startsWith('$baseAbs${Platform.pathSeparator}') && candidate != baseAbs) {
return null; // escaped the allowed root -- reject
}
return candidate;
}
void main() {
print(safeResolve(publicDir.path, '../secret.txt')); // null -- rejected
print(safeResolve(publicDir.path, 'hello.txt')); // resolves normally
}
// null
// /tmp/.../public/hello.txt
Uri.file(...).normalizePath() is the key step — without it, ..
segments are still present in the string and a naive startsWith check on
the un-normalized path can be fooled by the very sequence it's trying to
detect.
Cheat sheet¶
| Concept | Meaning |
|---|---|
sha256 alone for passwords |
Fast + deterministic — wrong properties for password storage |
Salt (Random.secure() bytes) |
Makes identical passwords hash differently |
| bcrypt / scrypt / Argon2 | Deliberately slow — what real password hashing should use (not sha256) |
Random() |
Predictable — fine for games, unsafe for tokens/salts |
Random.secure() |
Cryptographically secure source — use for anything security-sensitive |
Path traversal (../) |
User input joined onto a path can escape the intended directory |
Uri.file(path).normalizePath() |
Collapses ../. before validating the path is still in-bounds |
How It Actually Works¶
A plain hash function (sha256, md5) is designed to be fast — that's
its whole job as a cryptographic primitive for integrity checking, and it's
exactly the wrong property for password storage: an attacker with a stolen
hash database can compute billions of candidate hashes per second on
commodity GPU hardware, because there's no algorithmic reason a fast hash
takes longer than a single arithmetic-heavy pass over the input. Password
hashing algorithms (bcrypt, scrypt, Argon2) are deliberately
slow and memory-hard by design — they run the underlying mixing function
thousands of times in a loop, and/or require large amounts of RAM per
guess, specifically to make brute-force guessing computationally expensive
per attempt, which a general-purpose fast hash simply never was built to
do. A per-password random salt, stored alongside the hash, defeats
precomputed rainbow-table attacks by forcing an attacker to redo the
expensive hashing work for every single password rather than reusing one
precomputed table across an entire stolen database.
Random() (Dart's default, non-seeded constructor) is a standard
pseudorandom number generator optimized for statistical distribution and
speed, using an algorithm whose internal state can, in principle, be
inferred from a sequence of its outputs — fine for shuffling a game board,
unacceptable for anything security-sensitive because an attacker who can
observe or guess enough outputs could predict future ones. Random.secure()
instead sources entropy from the operating system's cryptographically
secure random number generator (e.g., /dev/urandom on POSIX, CryptGenRandom-
family APIs on Windows) — a fundamentally different data source designed to
resist exactly that kind of prediction, at some cost in speed since it may
block briefly on entropy availability at OS level.
Path traversal succeeds because filesystem path resolution happens after
string concatenation — a path like uploads/../../etc/passwd is a
syntactically valid relative path, and the OS's own path-resolution logic
(not Dart's) walks the .. segments up out of the intended directory before
ever checking whether the final resolved path was supposed to be reachable.
The fix (p.normalize() plus checking the resolved path stays under an
allowed root, or rejecting .. outright) has to happen in your own code
specifically because the OS filesystem API has no concept of "the directory
this request was supposed to be confined to" — that's an application-level
invariant.
Exercise¶
Write a function String? safeJoin(String root, String userPath) that
combines the normalize-then-verify pattern above into a single reusable
helper, rejecting both ../ traversal attempts and absolute paths (a
userPath starting with / that would otherwise bypass root entirely).
Test it against: a normal relative file, a ../../etc/passwd-style
traversal, and an absolute path like /etc/passwd passed directly as
userPath — all three should be handled correctly (one allowed, two
rejected).