Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ http-body = "1"
bytes = "1"
pin-project = "1"
url = "2"
ipnet = "2"
## Hashing
digestible = { git = "https://github.com/kshcherban/digestible.git", features = [
"base64",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- ABOUTME: Rolls back password reset token expiry storage.
-- ABOUTME: Restores the schema that predates expiry validation.
ALTER TABLE user_password_reset_tokens
DROP COLUMN expires_at;
11 changes: 11 additions & 0 deletions crates/core/migrations/20260827120000_password_reset_expiry.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- ABOUTME: Adds the expiry timestamp required by password reset token validation.
-- ABOUTME: Backfills existing tokens before enforcing the non-null invariant.
ALTER TABLE user_password_reset_tokens
ADD COLUMN expires_at TIMESTAMP WITH TIME ZONE;

UPDATE user_password_reset_tokens
SET expires_at = created_at + INTERVAL '1 day'
WHERE expires_at IS NULL;

ALTER TABLE user_password_reset_tokens
ALTER COLUMN expires_at SET NOT NULL;
6 changes: 4 additions & 2 deletions crates/core/src/database/entities/user/password_reset.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// ABOUTME: Persists password reset requests and validates their one-time tokens.
// ABOUTME: Tracks token expiry and the timestamp of terminal state changes.
use chrono::Local;
use rand::{SeedableRng, rngs::StdRng};
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -30,7 +32,7 @@ pub struct UserPasswordReset {
pub state: PasswordResetState,
pub token: String,
pub expires_at: DateTime,
pub used_at: DateTime,
pub state_changed_at: Option<DateTime>,
pub created_at: DateTime,
}
impl UserPasswordReset {
Expand Down Expand Up @@ -103,7 +105,7 @@ impl UserPasswordReset {
}
pub async fn set_used(&self, database: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query(
r#"UPDATE user_password_reset_tokens SET state = 'Used', used_at = NOW() WHERE id = $1"#,
r#"UPDATE user_password_reset_tokens SET state = 'Used', state_changed_at = NOW() WHERE id = $1"#,
)
.bind(self.id)
.execute(database)
Expand Down
62 changes: 62 additions & 0 deletions crates/core/src/egress.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// ABOUTME: Classifies IP addresses as globally routable for outbound traffic policy.
// ABOUTME: Shared by the Pkgly HTTP egress policy and the S3 storage egress resolver.
use std::net::IpAddr;

fn is_in_ipv6_network(address: u128, network: u128, prefix: u32) -> bool {
address >> (128 - prefix) == network >> (128 - prefix)
}

fn is_ietf_global_ipv6_exception(address: u128) -> bool {
matches!(
address,
0x2001_0001_0000_0000_0000_0000_0000_0001..=0x2001_0001_0000_0000_0000_0000_0000_0003
) || is_in_ipv6_network(address, 0x2001_0003_0000_0000_0000_0000_0000_0000, 32)
|| is_in_ipv6_network(address, 0x2001_0004_0112_0000_0000_0000_0000_0000, 48)
|| is_in_ipv6_network(address, 0x2001_0020_0000_0000_0000_0000_0000_0000, 28)
|| is_in_ipv6_network(address, 0x2001_0030_0000_0000_0000_0000_0000_0000, 28)
}

/// Returns true when `address` is globally routable.
///
/// Blocks loopback, private, link-local, multicast, unspecified, documentation,
/// benchmark, carrier-grade NAT, broadcast, all IPv4-mapped IPv6 addresses,
/// ULA, link-local, multicast, and reserved or documentation IPv6 ranges.
pub fn is_global(address: IpAddr) -> bool {
match address {
IpAddr::V4(ip) => {
let [a, b, c, d] = ip.octets();
!(a == 0
|| a == 10
|| a == 127
|| (a == 100 && (64..=127).contains(&b))
|| (a == 169 && b == 254)
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
|| (a == 192 && b == 0 && (c == 0 || c == 2))
|| (a == 192 && b == 88 && c == 99)
|| (a == 198 && (b == 18 || b == 19))
|| (a == 198 && b == 51 && c == 100)
|| (a == 203 && b == 0 && c == 113)
|| a >= 224
|| (a == 255 && b == 255 && c == 255 && d == 255))
}
IpAddr::V6(ip) => {
if ip.to_ipv4().is_some() {
return false;
}
let value = u128::from(ip);
let global_unicast =
is_in_ipv6_network(value, 0x2000_0000_0000_0000_0000_0000_0000_0000, 3);
let ietf_assignments =
is_in_ipv6_network(value, 0x2001_0000_0000_0000_0000_0000_0000_0000, 23);
global_unicast
&& (!ietf_assignments || is_ietf_global_ipv6_exception(value))
&& !is_in_ipv6_network(value, 0x2001_0db8_0000_0000_0000_0000_0000_0000, 32)
&& !is_in_ipv6_network(value, 0x2002_0000_0000_0000_0000_0000_0000_0000, 16)
&& !is_in_ipv6_network(value, 0x3fff_0000_0000_0000_0000_0000_0000_0000, 20)
}
}
}

#[cfg(test)]
mod tests;
97 changes: 97 additions & 0 deletions crates/core/src/egress/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// ABOUTME: Tests the global-routability classifier shared by egress policies.
// ABOUTME: Covers IPv4 and IPv6 reserved, private, and mapped ranges.
#![allow(clippy::expect_used)]

use super::is_global;
use std::net::IpAddr;

fn ipv4(value: &str) -> IpAddr {
value.parse().expect("valid ipv4 literal")
}

fn ipv6(value: &str) -> IpAddr {
value.parse().expect("valid ipv6 literal")
}

#[test]
fn blocks_ipv4_reserved_ranges() {
for value in [
"0.0.0.0",
"10.0.0.1",
"100.64.0.1",
"100.127.255.254",
"127.0.0.1",
"169.254.169.254",
"172.16.0.1",
"172.31.255.254",
"192.0.0.1",
"192.0.2.1",
"192.168.0.1",
"192.88.99.1",
"198.18.0.1",
"198.19.255.254",
"198.51.100.1",
"203.0.113.1",
"224.0.0.1",
"239.255.255.255",
"240.0.0.1",
"255.255.255.255",
] {
assert!(!is_global(ipv4(value)), "{value} must not be global");
}
}

#[test]
fn allows_ipv4_global_addresses() {
for value in [
"8.8.8.8",
"1.1.1.1",
"93.184.216.34",
"198.51.101.1",
"203.0.112.1",
] {
assert!(is_global(ipv4(value)), "{value} must be global");
}
}

#[test]
fn blocks_ipv6_reserved_ranges() {
for value in [
"::",
"::1",
"::ffff:127.0.0.1",
"::ffff:10.0.0.1",
"::ffff:169.254.169.254",
"::ffff:8.8.8.8",
"64:ff9b:1::1",
"100::1",
"100:0:0:1::1",
"fc00::1",
"fd12:3456:789a::1",
"fe80::1",
"ff00::1",
"ff02::1",
"2001:2::1",
"2001:db8::1",
"2001::1",
"2002::1",
"3fff::1",
"5f00::1",
] {
assert!(!is_global(ipv6(value)), "{value} must not be global");
}
}

#[test]
fn allows_ipv6_global_addresses() {
for value in [
"2001:1::1",
"2001:3::1",
"2001:4:112::1",
"2001:4860:4860::8888",
"2606:4700:4700::1111",
"2a00:1450:4007:810::200e",
] {
assert!(is_global(ipv6(value)), "{value} must be global");
}
}
1 change: 1 addition & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod user;
pub type ConfigTimeStamp = chrono::DateTime<chrono::FixedOffset>;
pub mod database;
pub mod egress;
pub mod logging;
pub mod repository;
pub mod storage;
Expand Down
23 changes: 23 additions & 0 deletions crates/core/src/storage/storage_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,29 @@ impl utoipa::ToSchema for StoragePath {
}

impl StoragePath {
/// Validates that all components are safe to use below a repository root.
pub fn validate(&self) -> Result<(), InvalidStoragePath> {
if self.components.iter().any(|component| {
matches!(component.as_ref(), "." | "..") || component.as_ref().contains(['\\', '\0'])
}) {
return Err(InvalidStoragePath::InvalidPath);
}
Ok(())
}

/// Parses a path received from an external request without normalizing it.
pub fn from_untrusted(value: &str) -> Result<Self, InvalidStoragePath> {
if value.is_empty() || value == "/" {
return Ok(Self::from(value));
}
if value.starts_with(['/', '\\']) || value.contains("//") {
return Err(InvalidStoragePath::InvalidPath);
}
let path = Self::from(value);
path.validate()?;
Ok(path)
}

/// The parent of the path is always a directory.
pub fn parent(self) -> Self {
let mut path = self.components;
Expand Down
14 changes: 14 additions & 0 deletions crates/core/src/storage/storage_path/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ fn double_slash() {
let path = StoragePath::from("test/test2//test3/");
assert_eq!(path.to_string(), "test/test2/test3/");
}

#[test]
fn rejects_traversal_components() {
for value in ["../secret", "a/../secret", "a/./secret", "a\\..\\secret"] {
assert!(StoragePath::from(value).validate().is_err());
}
assert!(StoragePath::from("safe/path.txt").validate().is_ok());
}

#[test]
fn accepts_repository_root_path() {
assert_eq!(StoragePath::from_untrusted("/").unwrap().to_string(), "/");
assert_eq!(StoragePath::from_untrusted("").unwrap().to_string(), "");
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct Test {
path: StoragePath,
Expand Down
3 changes: 3 additions & 0 deletions crates/storage/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ aws-smithy-runtime-api = "1"
lru = "0.16"
hex = "0.4"
url = { version = "2.4", features = ["serde"] }
ipnet.workspace = true
aws-smithy-http-client = { version = "1", features = ["rustls-ring"] }
parking_lot.workspace = true
[lints]
workspace = true

Expand Down
3 changes: 3 additions & 0 deletions crates/storage/src/local/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use super::{ExtensionError, ParentDirectoryDoesNotExist, PathCollisionError};
use crate::error::WrongFileType;
use nr_core::storage::InvalidStoragePath;

#[derive(Debug, thiserror::Error)]
pub enum LocalStorageError {
Expand All @@ -21,6 +22,8 @@ pub enum LocalStorageError {
InvalidConfigType(#[from] crate::InvalidConfigType),
#[error("Metadata update channel closed")]
MetaUpdateChannelClosed,
#[error(transparent)]
InvalidStoragePath(#[from] InvalidStoragePath),
#[error("Internal Unknown Error {0}")]
Other(Box<dyn std::error::Error + Send + Sync>),
}
Expand Down
Loading