Skip to main content

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-ByClaude Opus 4.6 (1M context) <noreply@anthropic.com>

Author: Max J. <87768334+MaxJMath@users.noreply.github.com> - 2026-04-26 02:41 UTC

Commit:

ec897efae95a35069f131b81166472f7e441945e

Parent:

a6b721f

102 files changed,

+1428 insertions,

-324 deletions

OldNewLine
@@ -1,6 +1,6 @@
1
1
[package]
2
2
name = "makenotwork"
3
version = "0.4.0"
3
version = "0.4.1"
4
4
edition = "2024"
5
5
license-file = "LICENSE"
6
6
OldNewLine
@@ -2,7 +2,12 @@
2
2
3
3
How to restore the Makenotwork database from a backup.
4
4
5
Backups are gzipped SQL dumps in `/opt/makenotwork/backups/`, named `makenotwork-YYYYMMDD-HHMMSS.sql.gz`. Kept for 30 days.
5
Backups are gzipped SQL dumps kept for 30 days in two locations:
6
7
- **Primary (Hetzner):** `/opt/makenotwork/backups/makenotwork-YYYYMMDD-HHMMSS.sql.gz`
8
- **Offsite (astra):** `/opt/backups/mnw/makenotwork-YYYYMMDD-HHMMSS.sql.gz` (synced after each backup via Tailscale)
9
10
If Hetzner is destroyed, the offsite copy on astra survives.
6
11
7
12
---
8
13
@@ -141,6 +146,21 @@
141
146
142
147
If the most recent backup is bad, use the previous day's backup.
143
148
149
### Hetzner destroyed — restore from offsite
150
151
If the Hetzner VPS is lost, backups survive on astra:
152
153
```bash
154
# From astra, list available backups
155
ls -lh /opt/backups/mnw/makenotwork-*.sql.gz
156
157
# Copy the latest to the new server
158
scp /opt/backups/mnw/makenotwork-YYYYMMDD-HHMMSS.sql.gz \
159
root@<new-server>:/opt/makenotwork/backups/
160
```
161
162
Then follow the Full Restore procedure above on the new server.
163
144
164
### No backups available
145
165
146
166
If all backups have been lost, the only option is to start fresh:
OldNewLine
@@ -55,3 +55,15 @@
55
55
# Summary
56
56
TOTAL=$(find "$BACKUP_DIR" -name "${DB_NAME}-*.sql.gz" | wc -l)
57
57
echo "[$(date -Iseconds)] Total backups on disk: $TOTAL"
58
59
# Sync to offsite host (best-effort — failure here does not fail the backup)
60
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
61
OFFSITE_SCRIPT="${SCRIPT_DIR}/sync-backup-offsite.sh"
62
if [ -x "$OFFSITE_SCRIPT" ]; then
63
"$OFFSITE_SCRIPT"
64
else
65
# Fallback: check deployed location
66
if [ -x /opt/makenotwork/sync-backup-offsite.sh ]; then
67
/opt/makenotwork/sync-backup-offsite.sh
68
fi
69
fi
OldNewLine
@@ -3,7 +3,7 @@
3
3
## Status
4
4
Done: All pre-beta phases. Active: Creator setup (Stripe), manual testing. Next: Soft launch.
5
5
6
v0.3.23. Audit grade A. ~1,233 tests.
6
v0.4.1. Audit grade A. ~1,233 tests.
7
7
8
8
---
9
9
@@ -112,41 +112,41 @@
112
112
- [ ] Consider GPU-accelerated analysis if volume warrants it
113
113
114
114
### Other scanning hardening
115
- [ ] Add timeout to YARA scanning (currently unbounded; crafted input could stall)
115
- [x] ~~Add timeout to YARA scanning. Fixed: `scanner.set_timeout(30s)` via yara-x native API.~~
116
116
- [ ] Cap ClamAV response buffer size (currently unbounded `read_to_end`)
117
- [ ] Nested archive detection: check magic bytes, not just file extensions
117
- [x] ~~Nested archive detection: check magic bytes, not just file extensions. Fixed: magic bytes check for ZIP, gzip, 7z, RAR in archive.rs.~~
118
118
119
119
---
120
120
121
121
## Code Fuzz Findings (2026-04-25)
122
122
123
Bugs found during adversarial code review. Ordered by severity.
123
Two rounds of adversarial code review. 31 findings total: 30 fixed, 1 accepted risk, 1 deferred.
124
124
125
### Critical
126
- [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`.~~
125
### Accepted Risk
126
- Idempotency check not atomic with operation — concurrent requests both execute (`db/idempotency.rs`). Safe because underlying ops are themselves idempotent.
127
127
128
### Serious
129
- [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.~~
130
- [x] ~~`validate_key_code` accepts `"----"` — empty word segments pass `all()` vacuously. Fixed: added `part.is_empty()` check + tests.~~
131
- [x] ~~Project image confirm missing S3 key prefix validation. Fixed: added `starts_with` user ID check in `project_image_confirm`.~~
132
- [x] ~~SyncKit auth lacks dummy hash. Fixed: added `DUMMY_HASH` + `verify_password` timing equalization.~~
133
- [x] ~~SyncUser extractor does not check user suspension. Fixed: added `get_user_by_id` + `is_suspended()` check.~~
134
- [x] ~~`delete_subscription_tier` TOCTOU. Fixed: wrapped in transaction with `FOR UPDATE` on the tier row.~~
128
### Deferred
129
- 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).
135
130
136
### Minor
137
- [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.~~
138
- [x] ~~CSRF body buffer (64KB) < global body limit (1MB). Fixed: increased buffer to 1MB to match global `RequestBodyLimitLayer`.~~
139
- [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.~~
140
- [ ] Idempotency check not atomic with operation — concurrent requests both execute (`db/idempotency.rs`). Safe only because underlying ops are themselves idempotent.
141
- [ ] `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.
131
### Resolved (28 findings)
132
All critical, serious, and minor findings from rounds 1 and 2 are fixed. See git history for details.
142
133
143
### Note
144
- [x] ~~`get_user_purchases` duplicate rows. Fixed: wrapped query in `DISTINCT ON (p.item_id)` subquery.~~
145
- [x] ~~`revoke_keys_by_transaction` LIMIT 1000. Fixed: removed the cap — bulk UPDATE already has no limit, SELECT now matches.~~
146
- [x] ~~YARA scanning has no timeout. Fixed: `scanner.set_timeout(30s)` via yara-x native API.~~
147
- [ ] 7-day SyncKit JWT with no per-user revocation (`constants.rs:37`). Stolen token usable for full window.
148
- [ ] Nested archive detection is extension-based only, not magic bytes (`scanning/archive.rs:81`).
149
- [ ] No rate limiting on read API routes — enables enumeration of tags, categories, domains (`api/mod.rs:366`).
134
---
135
136
## Creator Trust Audit (2026-04-25)
137
138
Systematic creator-perspective audit of docs, legal, code, and competitive positioning.
139
140
### Resolved (20+ findings)
141
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.
142
143
### Remaining
144
- [ ] No incident post-mortems or public historical incident log (process, not code)
145
146
### Competitive Positioning (acknowledged, not bugs)
147
- No free tier — deliberate tradeoff. Earn-back credit program planned.
148
- No mobile fan app — creator apps exist, no general fan app.
149
- No editorial discovery — search, tags, follows only. Interested in non-algorithmic discovery methods.
150
150
151
151
---
152
152
@@ -378,7 +378,7 @@
378
378
- [ ] Series/serial ordering, reading progress
379
379
- [ ] Traffic/referrer tracking
380
380
- [ ] Revisit admin system (currently config-based ADMIN_USER_ID)
381
- [ ] Test restore from backup
381
- [ ] Test restore from backup (offsite copy now available on astra — good candidate for test restore)
382
382
- [ ] S3 bucket versioning
383
383
- [ ] PDF stamping (watermark with buyer email/name — superseded by fingerprinting system, needs PDF library integration)
384
384
@@ -392,7 +392,7 @@
392
392
import/ (CSV converter, pipeline, intermediate format)
393
393
MNW/server/tests/
394
394
integration.rs, harness/, workflows/*.rs
395
MNW/server/migrations/ (001-057)
395
MNW/server/migrations/ (001-070)
396
396
MNW/server/templates/
397
397
MNW/server/deploy/
398
398
MNW/server/site-docs/public/, MNW/server/site-docs/unpublished/
OldNewLine
@@ -57,13 +57,15 @@
57
57
pub is_fan_plus: bool,
58
58
#[serde(default)]
59
59
pub creator_tier: Option<String>,
60
#[serde(default)]
61
pub deactivated: bool,
60
62
}
61
63
62
64
impl SessionUser {
63
/// Returns `Err(Forbidden)` if the user is suspended.
64
/// Call at the top of write routes that suspended users should not access.
65
/// Returns `Err(Forbidden)` if the user is suspended or deactivated.
66
/// Call at the top of write routes that suspended/deactivated users should not access.
65
67
pub fn check_not_suspended(&self) -> Result<(), AppError> {
66
if self.suspended {
68
if self.suspended || self.deactivated {
67
69
Err(AppError::Forbidden)
68
70
} else {
69
71
Ok(())
@@ -400,6 +402,7 @@
400
402
is_admin: true,
401
403
is_fan_plus: false,
402
404
creator_tier: None,
405
deactivated: false,
403
406
};
404
407
let config = Config {
405
408
host: "127.0.0.1".parse().unwrap(),
@@ -461,6 +464,7 @@
461
464
is_admin: false,
462
465
is_fan_plus: false,
463
466
creator_tier: None,
467
deactivated: false,
464
468
};
465
469
let config = Config {
466
470
host: "127.0.0.1".parse().unwrap(),
OldNewLine
@@ -105,7 +105,12 @@
105
105
106
106
// Secret key for signing tokens — required in production, random fallback in dev
107
107
let signing_secret = match std::env::var("SIGNING_SECRET") {
108
Ok(secret) => secret,
108
Ok(secret) => {
109
if secret.len() < 32 {
110
return Err(ConfigError::WeakSigningSecret);
111
}
112
secret
113
}
109
114
Err(_) => {
110
115
// If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start
111
116
let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
@@ -392,6 +397,8 @@
392
397
MissingDatabaseUrl,
393
398
#[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.")]
394
399
MissingSigningSecret,
400
#[error("SIGNING_SECRET must be at least 32 characters long")]
401
WeakSigningSecret,
395
402
}
396
403
397
404
#[cfg(test)]
OldNewLine
@@ -78,6 +78,9 @@
78
78
// API write endpoints (CRUD): burst 30, then 2/sec
79
79
pub const API_WRITE_RATE_LIMIT_MS: u64 = 500;
80
80
pub const API_WRITE_RATE_LIMIT_BURST: u32 = 30;
81
// API read endpoints (GET): burst 60, then 10/sec (prevents enumeration)
82
pub const API_READ_RATE_LIMIT_MS: u64 = 100;
83
pub const API_READ_RATE_LIMIT_BURST: u32 = 60;
81
84
// API export endpoints: burst 3, then 1/sec
82
85
pub const API_EXPORT_RATE_LIMIT_PER_SEC: u64 = 1;
83
86
pub const API_EXPORT_RATE_LIMIT_BURST: u32 = 3;
@@ -130,6 +133,8 @@
130
133
pub const GIT_DIFF_MAX_LINES: usize = 500; // Per-file line cap for diff display
131
134
pub const GIT_REPOS_PER_PAGE: usize = 30;
132
135
pub const GIT_FILE_LOG_MAX_WALK: usize = 1000; // Max commits to walk for per-file history
136
pub const GIT_RAW_MAX_BYTES: usize = 100 * 1024 * 1024; // 100 MB raw download limit
137
pub const GIT_UPLOAD_PACK_MAX_BYTES: usize = 10 * 1024 * 1024; // 10 MB upload-pack body limit
133
138
134
139
// -- Webhook security --
135
140
pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes
OldNewLine
@@ -172,6 +172,32 @@
172
172
})
173
173
}
174
174
175
/// IP key extractor that prefers `CF-Connecting-IP` (set by Cloudflare, cannot
176
/// be spoofed by clients) over `X-Forwarded-For` (which can be spoofed if the
177
/// proxy chain doesn't strip it). Falls back to `SmartIpKeyExtractor` behavior
178
/// when `CF-Connecting-IP` is absent (e.g., direct/dev access without Cloudflare).
179
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180
pub struct CloudflareIpKeyExtractor;
181
182
impl tower_governor::key_extractor::KeyExtractor for CloudflareIpKeyExtractor {
183
type Key = std::net::IpAddr;
184
185
fn extract<T>(&self, req: &axum::http::Request<T>) -> Result<Self::Key, tower_governor::errors::GovernorError> {
186
// Prefer CF-Connecting-IP (trusted, set by Cloudflare edge)
187
if let Some(ip) = req
188
.headers()
189
.get("cf-connecting-ip")
190
.and_then(|v: &axum::http::HeaderValue| v.to_str().ok())
191
.and_then(|s: &str| s.trim().parse::<std::net::IpAddr>().ok())
192
{
193
return Ok(ip);
194
}
195
196
// Fall back to SmartIpKeyExtractor behavior for non-Cloudflare environments
197
tower_governor::key_extractor::SmartIpKeyExtractor.extract(req)
198
}
199
}
200
175
201
/// Build a rate limiter config from a per-millisecond interval and burst size.
176
202
/// Includes `x-ratelimit-limit`, `x-ratelimit-remaining`, and `retry-after` headers.
177
203
pub fn rate_limiter_ms(
@@ -179,13 +205,13 @@
179
205
burst: u32,
180
206
) -> std::sync::Arc<
181
207
tower_governor::governor::GovernorConfig<
182
tower_governor::key_extractor::SmartIpKeyExtractor,
208
CloudflareIpKeyExtractor,
183
209
::governor::middleware::StateInformationMiddleware,
184
210
>,
185
211
> {
186
212
std::sync::Arc::new(
187
213
tower_governor::governor::GovernorConfigBuilder::default()
188
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
214
.key_extractor(CloudflareIpKeyExtractor)
189
215
.per_millisecond(ms)
190
216
.burst_size(burst)
191
217
.use_headers()
@@ -201,13 +227,13 @@
201
227
burst: u32,
202
228
) -> std::sync::Arc<
203
229
tower_governor::governor::GovernorConfig<
204
tower_governor::key_extractor::SmartIpKeyExtractor,
230
CloudflareIpKeyExtractor,
205
231
::governor::middleware::StateInformationMiddleware,
206
232
>,
207
233
> {
208
234
std::sync::Arc::new(
209
235
tower_governor::governor::GovernorConfigBuilder::default()
210
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
236
.key_extractor(CloudflareIpKeyExtractor)
211
237
.per_second(per_sec)
212
238
.burst_size(burst)
213
239
.use_headers()
OldNewLine
@@ -164,10 +164,37 @@
164
164
);
165
165
}
166
166
167
app.layer(middleware::from_fn(metrics::cache_control_middleware))
167
app.layer(middleware::from_fn(security_headers_middleware))
168
.layer(middleware::from_fn(metrics::cache_control_middleware))
168
169
.layer(middleware::from_fn(metrics::metrics_middleware))
169
170
.layer(middleware::from_fn(csrf::csrf_middleware))
170
171
.layer(middleware::from_fn_with_state(state.clone(), metrics::idempotency_middleware))
171
172
.layer(session_layer)
172
173
.layer(RequestBodyLimitLayer::new(1024 * 1024))
173
174
}
175
176
/// Middleware that sets security headers on all responses.
177
async fn security_headers_middleware(
178
request: axum::http::Request<axum::body::Body>,
179
next: middleware::Next,
180
) -> axum::response::Response {
181
let mut response = next.run(request).await;
182
let headers = response.headers_mut();
183
headers.insert(
184
axum::http::header::X_FRAME_OPTIONS,
185
HeaderValue::from_static("DENY"),
186
);
187
headers.insert(
188
axum::http::header::X_CONTENT_TYPE_OPTIONS,
189
HeaderValue::from_static("nosniff"),
190
);
191
headers.insert(
192
axum::http::header::REFERRER_POLICY,
193
HeaderValue::from_static("strict-origin-when-cross-origin"),
194
);
195
headers.insert(
196
axum::http::header::HeaderName::from_static("permissions-policy"),
197
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
198
);
199
response
200
}
OldNewLine
@@ -186,8 +186,8 @@
186
186
let method = request.method().to_string();
187
187
let path = request.uri().path().to_string();
188
188
189
// Check for cached response
190
if let Ok(Some(cached)) = crate::db::idempotency::get_cached_response(&state.db, &idem_key, user_id).await {
189
// Check for cached response (scoped to key + user + method + path)
190
if let Ok(Some(cached)) = crate::db::idempotency::get_cached_response(&state.db, &idem_key, user_id, &method, &path).await {
191
191
tracing::debug!(key = %idem_key, "returning cached idempotency response");
192
192
let status = StatusCode::from_u16(cached.status_code as u16).unwrap_or(StatusCode::OK);
193
193
return (status, cached.response_body).into_response();