Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,30 @@ impl<'r> FromRequest<'r> for CsrfProtected {
}
}

/// Maximum length of a post identifier, matching typical filesystem limits on
/// a single path component.
const MAX_POST_ID_LEN: usize = 255;

/// Returns `true` if `id` is a well-formed post identifier.
///
/// A valid identifier is a non-empty, length-bounded slug composed only of
/// ASCII letters, digits, hyphens, and underscores. Every identifier the
/// application produces satisfies this: [`generate_post_id`] emits
/// `[a-z0-9-]`, the static pages are lowercase words, and the Telegraph
/// archiver yields `[A-Za-z0-9_-]` slugs.
///
/// This is the trust boundary for untrusted path input. Because `.`, `/`, and
/// `\` are all rejected, a value that passes this check cannot express a
/// path-traversal sequence such as `../`, so it can be safely interpolated
/// into a `content/{id}.md` path.
fn is_valid_post_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_POST_ID_LEN
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}

fn generate_post_id(title: &str, storage: &PostStorage) -> Result<String, String> {
let now = Utc::now();
let date_str = now.format("%m-%d-%Y").to_string();
Expand Down Expand Up @@ -613,6 +637,15 @@ fn view_post(
&post_id
};

// Reject identifiers that could escape the content directory before any
// filesystem access takes place. See `is_valid_post_id`.
if !is_valid_post_id(actual_post_id) {
return Err((
Status::NotFound,
rocket::Either::Right(content::RawHtml(NOT_FOUND_HTML.to_string())),
));
}

if is_raw_request {
let file_path = format!("content/{}.md", actual_post_id);
return match std::fs::read_to_string(&file_path) {
Expand Down Expand Up @@ -1084,6 +1117,65 @@ mod tests {
assert!(id2.contains("hello-world-more"));
}

#[test]
fn test_is_valid_post_id_accepts_generated_ids() {
// Slugs produced by generate_post_id and the static pages.
assert!(is_valid_post_id("hello-world-09-01-2026"));
assert!(is_valid_post_id("hello-world-09-01-2026-3"));
assert!(is_valid_post_id("na-ab12-09-01-2026"));
assert!(is_valid_post_id("about"));
// Telegraph archiver slugs may contain uppercase and underscores.
assert!(is_valid_post_id("Sample-Page-12-15"));
assert!(is_valid_post_id("some_post_1"));
}

#[test]
fn test_is_valid_post_id_rejects_traversal() {
// Path separators and dot segments must never be accepted, in any
// form the router can deliver after percent-decoding.
assert!(!is_valid_post_id(""));
assert!(!is_valid_post_id(".."));
assert!(!is_valid_post_id("../README"));
assert!(!is_valid_post_id("../../etc/passwd"));
assert!(!is_valid_post_id("..\\README"));
assert!(!is_valid_post_id("foo/bar"));
assert!(!is_valid_post_id("foo.bar"));
assert!(!is_valid_post_id("post.md"));
assert!(!is_valid_post_id("a b"));
assert!(!is_valid_post_id("post\0"));
}

#[test]
fn test_is_valid_post_id_length_bound() {
let at_limit = "a".repeat(MAX_POST_ID_LEN);
let over_limit = "a".repeat(MAX_POST_ID_LEN + 1);
assert!(is_valid_post_id(&at_limit));
assert!(!is_valid_post_id(&over_limit));
}

#[test]
fn test_generated_ids_are_always_valid() {
// Every id generate_post_id can emit must pass the read-path guard,
// otherwise a freshly created post would 404. These titles exercise
// each slug branch: a normal slug, the symbol-only and whitespace-only
// fallbacks ("na-XXXX"), and the long-title truncation ("-etc").
let storage = Arc::new(Mutex::new(PostCache::new(128)));
for title in [
"Hello World",
"Special!@#$%Characters",
"!@#$%^&*()",
" ",
&"very long title ".repeat(40),
] {
let id = generate_post_id(title, &storage).unwrap();
assert!(
is_valid_post_id(&id),
"generated id {:?} rejected by is_valid_post_id",
id
);
}
}

#[test]
fn test_markdown_rendering_basic() {
let input = "This is *bold* text and **italic** text.";
Expand Down
Loading