server: clear five Phase 1 caps from Ultra Fuzz Run #4
- creator-tier forms now render the CSRF token (the page handler already
threaded csrf_token; only the template was missing the hidden input).
Without this, every authenticated click on Monthly/Annual 403'd.
- git_ssh.rs dispatch validates repo_name via validate_git_repo_name
before the DB lookup or git-shell command reconstruction. Same check
added at the db::git_repos::create_repo backstop so the HTTP smart-
protocol and CI internal-API call sites are covered without per-caller
duplication.
- lockout just_locked changes from `>= max_attempts` to `= max_attempts`
so the lockout email fires only on the crossing attempt, not on every
attempt at or above the threshold.
- cancel_pending_item_checkout moves from Skip("Phase 1 todo: ...") to
post_csrf. The purchase-page form already renders _csrf.
- promo use_count over-release: scheduler/cleanup dedupes promo_code_ids
via HashSet before iterating release_use_count. Cart checkouts produce
N pending-tx rows sharing one promo_code_id; the loop was releasing N
times per reservation.
Remaining Phase 1 (scanner streaming, scan_jobs retention, scanner pool
permit, broadcast bounded fan-out) have detailed plan docs at
_private/docs/mnw/server-docs/plans/ — referenced from server/todo.md.
6 files changed,
+20 insertions,
-9 deletions
| 38 |
38 |
|
ELSE locked_until
|
| 39 |
39 |
|
END
|
| 40 |
40 |
|
WHERE id = $1
|
| 41 |
|
- |
RETURNING failed_login_attempts, (failed_login_attempts >= $2) AS just_locked
|
|
41 |
+ |
RETURNING failed_login_attempts, (failed_login_attempts = $2) AS just_locked
|
| 42 |
42 |
|
"#,
|
| 43 |
43 |
|
)
|
| 44 |
44 |
|
.bind(user_id)
|
| 17 |
17 |
|
}
|
| 18 |
18 |
|
|
| 19 |
19 |
|
/// Register a new git repository for a user (default visibility: public).
|
|
20 |
+ |
///
|
|
21 |
+ |
/// Validates `name` against `validate_git_repo_name` as a defense-in-depth
|
|
22 |
+ |
/// backstop — the SSH dispatch path and HTTP smart-protocol path both call
|
|
23 |
+ |
/// this with names supplied by untrusted remote git clients.
|
| 20 |
24 |
|
#[tracing::instrument(skip_all)]
|
| 21 |
25 |
|
pub async fn create_repo(pool: &PgPool, user_id: UserId, name: &str) -> Result<DbGitRepo> {
|
|
26 |
+ |
crate::validation::validate_git_repo_name(name)?;
|
| 22 |
27 |
|
let repo = sqlx::query_as::<_, DbGitRepo>(
|
| 23 |
28 |
|
r#"
|
| 24 |
29 |
|
INSERT INTO git_repos (user_id, name)
|
| 7 |
7 |
|
use sqlx::PgPool;
|
| 8 |
8 |
|
|
| 9 |
9 |
|
use crate::db::{self, UserId, Username};
|
|
10 |
+ |
use crate::validation::validate_git_repo_name;
|
| 10 |
11 |
|
|
| 11 |
12 |
|
// ── Constants ──
|
| 12 |
13 |
|
|
| 76 |
77 |
|
let (operation, repo_path) = parse_ssh_command(original_cmd)?;
|
| 77 |
78 |
|
let (owner, repo_name) = parse_repo_path(&repo_path)?;
|
| 78 |
79 |
|
|
| 79 |
|
- |
// Validate the SSH-supplied owner string before any DB lookup or shell
|
| 80 |
|
- |
// reconstruction. `parse_repo_path` is a path-shape check, not a Username
|
| 81 |
|
- |
// syntax check — without this, a malformed owner could reach the DB layer
|
|
80 |
+ |
// Validate the SSH-supplied owner and repo name before any DB lookup or
|
|
81 |
+ |
// shell reconstruction. `parse_repo_path` is a path-shape check, not a
|
|
82 |
+ |
// syntax check — without this, a malformed name could reach the DB layer
|
| 82 |
83 |
|
// or end up embedded in the `git-shell -c` argument below.
|
| 83 |
84 |
|
let owner_username = Username::new(owner)
|
| 84 |
85 |
|
.map_err(|_| anyhow::anyhow!("repository not found"))?;
|
|
86 |
+ |
validate_git_repo_name(repo_name)
|
|
87 |
+ |
.map_err(|_| anyhow::anyhow!("repository not found"))?;
|
| 85 |
88 |
|
|
| 86 |
89 |
|
let owner_user = db::users::get_user_by_username(pool, &owner_username)
|
| 87 |
90 |
|
.await?
|
| 39 |
39 |
|
.route("/stripe/billing-portal", post_csrf(checkout::open_billing_portal))
|
| 40 |
40 |
|
.route("/stripe/creator-tier", post_csrf(checkout::create_creator_tier_checkout))
|
| 41 |
41 |
|
.route("/stripe/checkout/{item_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_checkout))
|
| 42 |
|
- |
// cancel-pending was in the old allowlist; behavior-preserving Skip.
|
| 43 |
|
- |
// The Phase 1 entry "cancel_pending_item_checkout CSRF gap" tracks
|
| 44 |
|
- |
// moving this to `post_csrf` once the form renders the token.
|
| 45 |
|
- |
.route("/stripe/checkout/{item_id}/cancel-pending", post_csrf_skip("Phase 1 todo: tighten to post_csrf", checkout::cancel_pending_item_checkout))
|
|
42 |
+ |
.route("/stripe/checkout/{item_id}/cancel-pending", post_csrf(checkout::cancel_pending_item_checkout))
|
| 46 |
43 |
|
.route("/stripe/checkout/project/{project_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_project_checkout))
|
| 47 |
44 |
|
.route("/stripe/subscribe/{tier_id}", post_csrf_skip(STRIPE_SESSION_SKIP, checkout::create_subscription_checkout))
|
| 48 |
45 |
|
.route("/stripe/checkout/tip/{recipient_id}", post_csrf_manual(
|
| 176 |
176 |
|
}
|
| 177 |
177 |
|
};
|
| 178 |
178 |
|
|
|
179 |
+ |
// Cart checkouts produce N pending-tx rows that share a promo_code_id;
|
|
180 |
+ |
// release once per reservation, not once per row.
|
|
181 |
+ |
let unique_promo_ids: std::collections::HashSet<_> = promo_ids.into_iter().flatten().collect();
|
|
182 |
+ |
|
| 179 |
183 |
|
let mut released = 0i64;
|
| 180 |
|
- |
for pc_id in promo_ids.into_iter().flatten() {
|
|
184 |
+ |
for pc_id in unique_promo_ids {
|
| 181 |
185 |
|
if let Err(e) = db::promo_codes::release_use_count(&state.db, pc_id).await {
|
| 182 |
186 |
|
tracing::warn!(promo_code_id = %pc_id, error = ?e, "failed to release promo code use count");
|
| 183 |
187 |
|
} else {
|
| 78 |
78 |
|
<div class="meta creator-tier-storage mb-3">{{ tier_storage }}</div>
|
| 79 |
79 |
|
<div class="creator-tier-buttons">
|
| 80 |
80 |
|
<form method="post" action="/stripe/creator-tier" class="m-0">
|
|
81 |
+ |
{% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
|
| 81 |
82 |
|
<input type="hidden" name="tier" value="{{ tier_key }}">
|
| 82 |
83 |
|
<input type="hidden" name="interval" value="monthly">
|
| 83 |
84 |
|
<button type="submit" class="btn-primary creator-tier-btn" data-loading-text="Redirecting to Stripe...">Monthly</button>
|
| 84 |
85 |
|
</form>
|
| 85 |
86 |
|
<form method="post" action="/stripe/creator-tier" class="m-0">
|
|
87 |
+ |
{% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}
|
| 86 |
88 |
|
<input type="hidden" name="tier" value="{{ tier_key }}">
|
| 87 |
89 |
|
<input type="hidden" name="interval" value="annual">
|
| 88 |
90 |
|
<button type="submit" class="btn-secondary creator-tier-btn" data-loading-text="Redirecting to Stripe...">Annual (save 10%)</button>
|