Skip to main content

Security audit: fix 23 flaws from adversarial code fuzz, harden test suite Round 1 fixes (from prior audit, unstaged): - S3 key prefix validation on all confirm endpoints - Unicode homograph prevention (is_ascii_alphanumeric) - Bundle refund child transaction revocation (migration 061) - Tip amount overflow guard ($1-$10K bounds) - Project member split TOCTOU (SELECT FOR UPDATE) - SSE connection limit with drop guard - Backup code transaction wrapping - Storage increment ordering (before DB writes) - Constant-time compare via SHA-256 pre-hash - Login timing equalization (dummy Argon2 hash) - Atomic lockout increment (single SQL UPDATE) - Tip refund webhook handling - OTA semver ordering - SUM ::BIGINT casts in analytics - N+1 query batching (UNNEST, ANY) - Admin query LIMIT caps - DB ownership checks on mutation functions Round 2 fixes (fuzz audit, this session): - Partial refund handling: extract amount_refunded from Charge, skip revocation on partial refunds (previously any refund revoked all access) - Propagate complete_transaction errors so Stripe retries webhooks - Unique public project slug index (migration 062) + deterministic ORDER BY on get_public_project_by_slug - Move file type rejection before try_increment_storage to prevent storage counter leak on rejected Download/Insertion/Media types - Reject upload confirm when S3 object_size returns None instead of defaulting to 0 bytes (6 confirm handlers) - PWYW $10K max cap (matching tip ceiling) - Revenue split rounding with remainder distribution - Fee display clamped to 0 for sub-31-cent items - Password 128-char cap on login and SyncKit auth paths - Session cycle before storing pending_2fa_user_id - slugify() restricted to ASCII alphanumeric - SyncKit app is_active check in JWT extractor - SSE sync_notify and sse_connections pruning on disconnect - Image content-type: reject unrecognized magic bytes for Cover/MediaImage Test suite: - Template database for integration tests (CREATE DATABASE ... TEMPLATE) - SSE streaming tests marked #[ignore] to prevent binary hang - New integration tests for auth, blog, content, license keys, payments

  • Co-Authored-ByClaude Opus 4.6 (1M context) <noreply@anthropic.com>

Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-25 07:14 UTC

Commit:

514ead900b1068762c70e1f978c5507c70cf854e

Parent:

b3f80f6

66 files changed,

+2558 insertions,

-658 deletions

OldNewLine
@@ -41,6 +41,7 @@
41
41
pub const SYNC_LOG_RETAIN_DAYS: i64 = 90;
42
42
pub const SYNCKIT_MAX_BLOB_SIZE_BYTES: i64 = 500 * 1024 * 1024; // 500 MB
43
43
pub const SYNCKIT_BLOB_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hour
44
pub const SYNCKIT_MAX_SSE_CONNECTIONS_PER_USER: usize = 10;
44
45
45
46
// -- Subscriptions --
46
47
pub const MIN_SUBSCRIPTION_PRICE_CENTS: i32 = 100; // $1.00 minimum
OldNewLine
@@ -53,13 +53,17 @@
53
53
}
54
54
55
55
/// Constant-time string comparison to prevent timing attacks.
56
///
57
/// Hashes both inputs with SHA-256 before comparing to avoid leaking
58
/// the length of the expected value via early return.
56
59
pub fn constant_time_compare(a: &str, b: &str) -> bool {
57
if a.len() != b.len() {
58
return false;
59
}
60
use sha2::{Sha256, Digest};
61
62
let hash_a = Sha256::digest(a.as_bytes());
63
let hash_b = Sha256::digest(b.as_bytes());
60
64
61
65
let mut result = 0u8;
62
for (x, y) in a.bytes().zip(b.bytes()) {
66
for (x, y) in hash_a.iter().zip(hash_b.iter()) {
63
67
result |= x ^ y;
64
68
}
65
69
result == 0
@@ -72,7 +76,7 @@
72
76
let slug: String = title
73
77
.to_lowercase()
74
78
.chars()
75
.map(|c| if c.is_alphanumeric() { c } else { '-' })
79
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
76
80
.collect();
77
81
// Collapse multiple hyphens, trim from ends
78
82
let mut result = String::new();
@@ -268,7 +272,8 @@
268
272
}
269
273
let fee = (price_cents as f64 * crate::constants::STRIPE_FEE_PERCENTAGE
270
274
+ crate::constants::STRIPE_FEE_FIXED_CENTS) as i32;
271
(fee, price_cents - fee)
275
let creator_receives = (price_cents - fee).max(0);
276
(fee.min(price_cents), creator_receives)
272
277
}
273
278
274
279
/// Sanitize a string for use as a CSV cell value.
OldNewLine
@@ -84,6 +84,8 @@
84
84
/// SSE push notification channels for SyncKit subscribers.
85
85
/// Key: (app_id, user_id), Value: broadcast sender that SSE connections subscribe to.
86
86
pub sync_notify: Arc<DashMap<(SyncAppId, UserId), tokio::sync::broadcast::Sender<()>>>,
87
/// Concurrent SSE connection count per user (for rate limiting).
88
pub sse_connections: Arc<DashMap<UserId, std::sync::atomic::AtomicUsize>>,
87
89
/// Prometheus metrics handle for rendering the admin dashboard. `None` in tests.
88
90
pub metrics_handle: Option<metrics_exporter_prometheus::PrometheusHandle>,
89
91
}
OldNewLine
@@ -255,6 +255,7 @@
255
255
domain_cache,
256
256
restart_at: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)),
257
257
sync_notify: std::sync::Arc::new(dashmap::DashMap::new()),
258
sse_connections: std::sync::Arc::new(dashmap::DashMap::new()),
258
259
metrics_handle: Some(makenotwork::metrics::init()),
259
260
};
260
261
OldNewLine
@@ -186,13 +186,16 @@
186
186
fn validate_amount(&self, amount_cents: i32) -> Result<(), String> {
187
187
let min = self.min_cents.unwrap_or(0);
188
188
if amount_cents < min {
189
Err(format!(
189
return Err(format!(
190
190
"Amount must be at least ${:.2}",
191
191
min as f64 / 100.0
192
))
193
} else {
194
Ok(())
192
));
195
193
}
194
// Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges
195
if amount_cents > 1_000_000 {
196
return Err("Amount cannot exceed $10,000".to_string());
197
}
198
Ok(())
196
199
}
197
200
198
201
fn kind(&self) -> db::PricingKind {
OldNewLine
@@ -100,6 +100,15 @@
100
100
101
101
let claims = decode_sync_token(secret, token)?;
102
102
103
// Verify the app is still active (JWT may outlive app deactivation)
104
let app = crate::db::synckit::get_sync_app_by_id(&state.db, claims.app)
105
.await
106
.map_err(|_| AppError::Internal(anyhow::anyhow!("Failed to verify sync app")))?
107
.ok_or(AppError::Unauthorized)?;
108
if !app.is_active {
109
return Err(AppError::Unauthorized);
110
}
111
103
112
Ok(SyncUser {
104
113
user_id: claims.sub,
105
114
app_id: claims.app,