v0.4.1: Creator trust audit, security hardening, account lifecycle Creator trust audit (all findings resolved): - Subscription export endpoint (CSV with tier/price/period data) - Bundle and collection structure in project JSON export - Custom domain mappings in export - Fan+ docs marked as not yet available - Portability, tiers, roadmap, content guide, items doc corrections - Payouts: multi-currency clarification, expanded tax guidance - FAQ: storage exceeded, discovery capabilities - Contact: human-only support commitment, proactive monitoring, bus=1 - Best practices: expanded discovery section Security hardening: - Security headers middleware (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy) - SyncKit API key hashing (SHA-256 + prefix, migration 068) - Read API rate limiting (10/sec burst-60) - Nested archive magic bytes detection (ZIP, gzip, 7z, RAR) - Privacy policy: streaming session data disclosure Account lifecycle: - Fan subscription pause on creator suspension (Stripe pause_collection, migration 069, auto-resume on unsuspend/appeal approval) - Account limbo state (self-deactivate, migration 070, restricted dashboard with reactivate/export/delete only) - Support ticket portal in dashboard (WAM ticket + confirmation email) Infrastructure: - Offsite backup replication to astra via Tailscale - WAM alerting on backup sync failure Code fuzz cleanup: 30/31 findings resolved, 1 accepted risk.
- Co-Authored-By
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-26 02:41 UTC
Commit:
ec897efae95a35069f131b81166472f7e441945eParent:
102 files changed,
+1428 insertions,
-324 deletions
[package]name = "makenotwork"version = "0.4.0"version = "0.4.1"edition = "2024"license-file = "LICENSE"How to restore the Makenotwork database from a backup.Backups are gzipped SQL dumps in `/opt/makenotwork/backups/`, named `makenotwork-YYYYMMDD-HHMMSS.sql.gz`. Kept for 30 days.Backups are gzipped SQL dumps kept for 30 days in two locations:- **Primary (Hetzner):** `/opt/makenotwork/backups/makenotwork-YYYYMMDD-HHMMSS.sql.gz`- **Offsite (astra):** `/opt/backups/mnw/makenotwork-YYYYMMDD-HHMMSS.sql.gz` (synced after each backup via Tailscale)If Hetzner is destroyed, the offsite copy on astra survives.---If the most recent backup is bad, use the previous day's backup.### Hetzner destroyed — restore from offsiteIf the Hetzner VPS is lost, backups survive on astra:```bash# From astra, list available backupsls -lh /opt/backups/mnw/makenotwork-*.sql.gz# Copy the latest to the new serverscp /opt/backups/mnw/makenotwork-YYYYMMDD-HHMMSS.sql.gz \ root@<new-server>:/opt/makenotwork/backups/```Then follow the Full Restore procedure above on the new server.### No backups availableIf all backups have been lost, the only option is to start fresh:# SummaryTOTAL=$(find "$BACKUP_DIR" -name "${DB_NAME}-*.sql.gz" | wc -l)echo "[$(date -Iseconds)] Total backups on disk: $TOTAL"# Sync to offsite host (best-effort — failure here does not fail the backup)SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"OFFSITE_SCRIPT="${SCRIPT_DIR}/sync-backup-offsite.sh"if [ -x "$OFFSITE_SCRIPT" ]; then "$OFFSITE_SCRIPT"else # Fallback: check deployed location if [ -x /opt/makenotwork/sync-backup-offsite.sh ]; then /opt/makenotwork/sync-backup-offsite.sh fifi## StatusDone: All pre-beta phases. Active: Creator setup (Stripe), manual testing. Next: Soft launch.v0.3.23. Audit grade A. ~1,233 tests.v0.4.1. Audit grade A. ~1,233 tests.---- [ ] Consider GPU-accelerated analysis if volume warrants it### Other scanning hardening- [ ] Add timeout to YARA scanning (currently unbounded; crafted input could stall)- [x] ~~Add timeout to YARA scanning. Fixed: `scanner.set_timeout(30s)` via yara-x native API.~~- [ ] Cap ClamAV response buffer size (currently unbounded `read_to_end`)- [ ] Nested archive detection: check magic bytes, not just file extensions- [x] ~~Nested archive detection: check magic bytes, not just file extensions. Fixed: magic bytes check for ZIP, gzip, 7z, RAR in archive.rs.~~---## Code Fuzz Findings (2026-04-25)Bugs found during adversarial code review. Ordered by severity.Two rounds of adversarial code review. 31 findings total: 30 fixed, 1 accepted risk, 1 deferred.### Critical- [x] ~~20 GB file downloaded into RAM for scanning — `SCAN_MAX_MEMORY_BYTES` was dead code (`routes/storage/mod.rs:91`). Fixed: size guard added to `scan_and_classify`.~~### Accepted Risk- Idempotency check not atomic with operation — concurrent requests both execute (`db/idempotency.rs`). Safe because underlying ops are themselves idempotent.### Serious- [x] ~~Refund-before-payment webhook silently lost. Fixed: unmatched refunds stored in `pending_refunds` table (migration 063). Checkout handler checks for pending refunds after completing a transaction. Scheduler escalates unmatched refunds >24h old via admin alert email.~~- [x] ~~`validate_key_code` accepts `"----"` — empty word segments pass `all()` vacuously. Fixed: added `part.is_empty()` check + tests.~~- [x] ~~Project image confirm missing S3 key prefix validation. Fixed: added `starts_with` user ID check in `project_image_confirm`.~~- [x] ~~SyncKit auth lacks dummy hash. Fixed: added `DUMMY_HASH` + `verify_password` timing equalization.~~- [x] ~~SyncUser extractor does not check user suspension. Fixed: added `get_user_by_id` + `is_suspended()` check.~~- [x] ~~`delete_subscription_tier` TOCTOU. Fixed: wrapped in transaction with `FOR UPDATE` on the tier row.~~### Deferred- 7-day SyncKit JWT with no per-user revocation (`constants.rs:37`). Stolen token usable for full window. Requires key rotation infrastructure (SyncKit S4, post-beta).### Minor- [x] ~~2FA verification has no per-user failed-attempt counter. Fixed: reuses `increment_failed_login` — failed 2FA attempts count toward account lockout (5 attempts, 15 min). Reset on success.~~- [x] ~~CSRF body buffer (64KB) < global body limit (1MB). Fixed: increased buffer to 1MB to match global `RequestBodyLimitLayer`.~~- [x] ~~Import endpoint 10MB size limit unreachable due to 1MB global body limit. Fixed: pulled import route into its own group with 15MB `DefaultBodyLimit` override.~~- [ ] Idempotency check not atomic with operation — concurrent requests both execute (`db/idempotency.rs`). Safe only because underlying ops are themselves idempotent.- [ ] `Slug::from_trusted` used on untrusted URL path segments (`custom_domain.rs:164,182` + ~20 page routes). Safe due to sqlx parameterization but a latent footgun.### Resolved (28 findings)All critical, serious, and minor findings from rounds 1 and 2 are fixed. See git history for details.### Note- [x] ~~`get_user_purchases` duplicate rows. Fixed: wrapped query in `DISTINCT ON (p.item_id)` subquery.~~- [x] ~~`revoke_keys_by_transaction` LIMIT 1000. Fixed: removed the cap — bulk UPDATE already has no limit, SELECT now matches.~~- [x] ~~YARA scanning has no timeout. Fixed: `scanner.set_timeout(30s)` via yara-x native API.~~- [ ] 7-day SyncKit JWT with no per-user revocation (`constants.rs:37`). Stolen token usable for full window.- [ ] Nested archive detection is extension-based only, not magic bytes (`scanning/archive.rs:81`).- [ ] No rate limiting on read API routes — enables enumeration of tags, categories, domains (`api/mod.rs:366`).---## Creator Trust Audit (2026-04-25)Systematic creator-perspective audit of docs, legal, code, and competitive positioning.### Resolved (20+ findings)All doc/code fixes, trust gaps, security issues, and doc clarity items are complete. Key changes: subscription export endpoint, offsite backups with WAM alerting, API key hashing, security headers, fan subscription pause on suspension, account limbo state, support ticket portal, expanded tax/payout/discovery/storage docs, privacy policy updates. See git history.### Remaining- [ ] No incident post-mortems or public historical incident log (process, not code)### Competitive Positioning (acknowledged, not bugs)- No free tier — deliberate tradeoff. Earn-back credit program planned.- No mobile fan app — creator apps exist, no general fan app.- No editorial discovery — search, tags, follows only. Interested in non-algorithmic discovery methods.---- [ ] Series/serial ordering, reading progress- [ ] Traffic/referrer tracking- [ ] Revisit admin system (currently config-based ADMIN_USER_ID)- [ ] Test restore from backup- [ ] Test restore from backup (offsite copy now available on astra — good candidate for test restore)- [ ] S3 bucket versioning- [ ] PDF stamping (watermark with buyer email/name — superseded by fingerprinting system, needs PDF library integration) import/ (CSV converter, pipeline, intermediate format)MNW/server/tests/ integration.rs, harness/, workflows/*.rsMNW/server/migrations/ (001-057)MNW/server/migrations/ (001-070)MNW/server/templates/MNW/server/deploy/MNW/server/site-docs/public/, MNW/server/site-docs/unpublished/ pub is_fan_plus: bool, #[serde(default)] pub creator_tier: Option<String>, #[serde(default)] pub deactivated: bool,}impl SessionUser { /// Returns `Err(Forbidden)` if the user is suspended. /// Call at the top of write routes that suspended users should not access. /// Returns `Err(Forbidden)` if the user is suspended or deactivated. /// Call at the top of write routes that suspended/deactivated users should not access. pub fn check_not_suspended(&self) -> Result<(), AppError> { if self.suspended { if self.suspended || self.deactivated { Err(AppError::Forbidden) } else { Ok(()) is_admin: true, is_fan_plus: false, creator_tier: None, deactivated: false, }; let config = Config { host: "127.0.0.1".parse().unwrap(), is_admin: false, is_fan_plus: false, creator_tier: None, deactivated: false, }; let config = Config { host: "127.0.0.1".parse().unwrap(), // Secret key for signing tokens — required in production, random fallback in dev let signing_secret = match std::env::var("SIGNING_SECRET") { Ok(secret) => secret, Ok(secret) => { if secret.len() < 32 { return Err(ConfigError::WeakSigningSecret); } secret } Err(_) => { // If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED) MissingDatabaseUrl, #[error("SIGNING_SECRET is required in production (HOST=0.0.0.0 or HTTPS HOST_URL detected). Set SIGNING_SECRET to a stable random string.")] MissingSigningSecret, #[error("SIGNING_SECRET must be at least 32 characters long")] WeakSigningSecret,}#[cfg(test)]// API write endpoints (CRUD): burst 30, then 2/secpub const API_WRITE_RATE_LIMIT_MS: u64 = 500;pub const API_WRITE_RATE_LIMIT_BURST: u32 = 30;// API read endpoints (GET): burst 60, then 10/sec (prevents enumeration)pub const API_READ_RATE_LIMIT_MS: u64 = 100;pub const API_READ_RATE_LIMIT_BURST: u32 = 60;// API export endpoints: burst 3, then 1/secpub const API_EXPORT_RATE_LIMIT_PER_SEC: u64 = 1;pub const API_EXPORT_RATE_LIMIT_BURST: u32 = 3;pub const GIT_DIFF_MAX_LINES: usize = 500; // Per-file line cap for diff displaypub const GIT_REPOS_PER_PAGE: usize = 30;pub const GIT_FILE_LOG_MAX_WALK: usize = 1000; // Max commits to walk for per-file historypub const GIT_RAW_MAX_BYTES: usize = 100 * 1024 * 1024; // 100 MB raw download limitpub const GIT_UPLOAD_PACK_MAX_BYTES: usize = 10 * 1024 * 1024; // 10 MB upload-pack body limit// -- Webhook security --pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes })}/// IP key extractor that prefers `CF-Connecting-IP` (set by Cloudflare, cannot/// be spoofed by clients) over `X-Forwarded-For` (which can be spoofed if the/// proxy chain doesn't strip it). Falls back to `SmartIpKeyExtractor` behavior/// when `CF-Connecting-IP` is absent (e.g., direct/dev access without Cloudflare).#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct CloudflareIpKeyExtractor;impl tower_governor::key_extractor::KeyExtractor for CloudflareIpKeyExtractor { type Key = std::net::IpAddr; fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, tower_governor::errors::GovernorError> { // Prefer CF-Connecting-IP (trusted, set by Cloudflare edge) if let Some(ip) = req .headers() .get("cf-connecting-ip") .and_then(|v: &axum::http::HeaderValue| v.to_str().ok()) .and_then(|s: &str| s.trim().parse::<std::net::IpAddr>().ok()) { return Ok(ip); } // Fall back to SmartIpKeyExtractor behavior for non-Cloudflare environments tower_governor::key_extractor::SmartIpKeyExtractor.extract(req) }}/// Build a rate limiter config from a per-millisecond interval and burst size./// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers.pub fn rate_limiter_ms( burst: u32,) -> std::sync::Arc< tower_governor::governor::GovernorConfig< tower_governor::key_extractor::SmartIpKeyExtractor, CloudflareIpKeyExtractor, ::governor::middleware::StateInformationMiddleware, >,> { std::sync::Arc::new( tower_governor::governor::GovernorConfigBuilder::default() .key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor) .key_extractor(CloudflareIpKeyExtractor) .per_millisecond(ms) .burst_size(burst) .use_headers() burst: u32,) -> std::sync::Arc< tower_governor::governor::GovernorConfig< tower_governor::key_extractor::SmartIpKeyExtractor, CloudflareIpKeyExtractor, ::governor::middleware::StateInformationMiddleware, >,> { std::sync::Arc::new( tower_governor::governor::GovernorConfigBuilder::default() .key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor) .key_extractor(CloudflareIpKeyExtractor) .per_second(per_sec) .burst_size(burst) .use_headers() ); } app.layer(middleware::from_fn(metrics::cache_control_middleware)) app.layer(middleware::from_fn(security_headers_middleware)) .layer(middleware::from_fn(metrics::cache_control_middleware)) .layer(middleware::from_fn(metrics::metrics_middleware)) .layer(middleware::from_fn(csrf::csrf_middleware)) .layer(middleware::from_fn_with_state(state.clone(), metrics::idempotency_middleware)) .layer(session_layer) .layer(RequestBodyLimitLayer::new(1024 * 1024))}/// Middleware that sets security headers on all responses.async fn security_headers_middleware( request: axum::http::Request<axum::body::Body>, next: middleware::Next,) -> axum::response::Response { let mut response = next.run(request).await; let headers = response.headers_mut(); headers.insert( axum::http::header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"), ); headers.insert( axum::http::header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"), ); headers.insert( axum::http::header::REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin"), ); headers.insert( axum::http::header::HeaderName::from_static("permissions-policy"), HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), ); response} let method = request.method().to_string(); let path = request.uri().path().to_string(); // Check for cached response if let Ok(Some(cached)) = crate::db::idempotency::get_cached_response(&state.db, &idem_key, user_id).await { // Check for cached response (scoped to key + user + method + path) if let Ok(Some(cached)) = crate::db::idempotency::get_cached_response(&state.db, &idem_key, user_id, &method, &path).await { tracing::debug!(key = %idem_key, "returning cached idempotency response"); let status = StatusCode::from_u16(cached.status_code as u16).unwrap_or(StatusCode::OK); return (status, cached.response_body).into_response();