-
-
Notifications
You must be signed in to change notification settings - Fork 16
Description
Overview
Add a Rust validation layer to the Discord server submission pipeline so every request passes through five layers of defense before touching the database:
Client → Rust (axum-discordsh) → Edge Function → ProxyRPC → ServiceRPC → SQL
Currently the frontend calls the edge function directly (POST /functions/v1/discordsh with command: "server.submit"). This issue adds axum-discordsh as the entry point — validating, sanitizing, and rate-limiting before forwarding to the edge function. The edge function, proxy RPC, service RPC, and SQL constraints remain as defense-in-depth layers.
Current Flow (No Rust Layer)
ReactSubmitForm.tsx
→ POST https://supabase.kbve.com/functions/v1/discordsh
body: { command: "server.submit", server_id, name, summary, invite_code, captcha_token, ... }
header: Authorization: Bearer {jwt}
→ Edge Function (server.ts)
→ validateSnowflake(), requireNonEmpty(), verifyCaptcha()
→ supabase.rpc("proxy_submit_server", { ... })
→ proxy_submit_server() — derives owner from auth.uid(), rate limits
→ service_submit_server() — advisory lock, duplicate check, INSERT
→ CHECK constraints + validation functions (is_safe_text, is_safe_url, etc.)
Proposed Flow (Rust First)
ReactSubmitForm.tsx
→ POST /api/servers/submit (axum-discordsh)
body: { server_id, name, summary, invite_code, captcha_token, ... }
header: Authorization: Bearer {jwt}
→ Rust handler: validate, sanitize, rate-limit
→ reqwest POST to Edge Function (forwarding validated payload + user token)
→ Edge Function: re-validates (defense in depth), verifies captcha
→ supabase.rpc("proxy_submit_server", { ... })
→ proxy → service → SQL (unchanged)
1. Rust API Route — POST /api/servers/submit
File: apps/discordsh/axum-discordsh/src/transport/https.rs
Add route under a new /api/servers scope.
Request Schema
#[derive(Deserialize)]
pub struct SubmitServerRequest {
pub server_id: String, // Discord snowflake (17-20 digits)
pub name: String, // 1-100 chars
pub summary: String, // 1-200 chars
pub invite_code: String, // 2-32 alphanumeric + underscore/hyphen
pub captcha_token: String, // hCaptcha response token
pub description: Option<String>, // 0-2000 chars
pub icon_url: Option<String>, // https:// URL, max 2048
pub banner_url: Option<String>, // https:// URL, max 2048
pub categories: Option<Vec<i16>>, // 1-12, max 3
pub tags: Option<Vec<String>>, // max 10, lowercase slug
}Validation Logic (Rust Layer)
All validation should happen before the edge function call:
- Snowflake format — regex
^\d{17,20}$ - Name — non-empty after trim, 1-100 chars, reject control chars (C0/C1, zero-width, bidi overrides) — mirrors
discordsh.is_safe_text() - Summary — non-empty after trim, 1-200 chars, same safe-text rules
- Invite code — regex
^[a-zA-Z0-9_-]{2,32}$ - Description (if present) — 0-2000 chars, safe-text rules
- Icon/banner URL (if present) — must start with
https://, max 2048 chars, no whitespace/control chars - Categories (if present) — max 3 elements, each 1-12, no duplicates
- Tags (if present) — max 10, each 1-50 chars, regex
^[a-z0-9][a-z0-9_-]*$ - Captcha token — non-empty string (actual verification delegated to edge function which holds the HCAPTCHA_SECRET)
Safe-Text Validation (Rust)
Port the PostgreSQL is_safe_text() logic to Rust:
fn is_safe_text(s: &str) -> bool {
let trimmed = s.trim();
if trimmed.is_empty() { return false; }
for ch in trimmed.chars() {
// Reject C0 control chars (except tab, newline, CR)
if ch < '\u{0020}' && ch != '\t' && ch != '\n' && ch != '\r' { return false; }
// Reject C1 control chars
if ('\u{007F}'..='\u{009F}').contains(&ch) { return false; }
// Reject zero-width chars
if matches!(ch, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}') { return false; }
// Reject bidi overrides
if ('\u{202A}'..='\u{202E}').contains(&ch) { return false; }
if ('\u{2066}'..='\u{2069}').contains(&ch) { return false; }
}
true
}Error Response Format
{
"success": false,
"errors": [
{ "field": "server_id", "message": "Must be a 17-20 digit Discord snowflake" },
{ "field": "name", "message": "Contains invalid control characters" }
]
}Return all validation errors at once (not just the first one) for better UX.
2. Rust Rate Limiting
In-Memory Rate Limit (Per-IP)
Before forwarding to the edge function:
- Submission rate limit — max 3 submissions per IP per 5 minutes
- Use a
DashMap<IpAddr, Vec<Instant>>or similar structure onAppState - Sliding window: prune entries older than 5 minutes on each check
- Return
429 Too Many RequestswithRetry-Afterheader
This is a first line of defense — the database-level rate limit (max 5 pending per user) remains as the authoritative check.
Tasks
- Add rate limiter state to
AppStatestruct instate.rs - Create rate limit middleware or extractor
- Background task to periodically prune stale entries
3. JWT Forwarding to Edge Function
Logical Flow
Rust handler receives request:
1. Extract Authorization header (Bearer token)
2. Validate all fields locally (reject early if invalid)
3. Check IP rate limit (reject early if exceeded)
4. Forward to edge function via reqwest:
POST {EDGE_FUNCTION_URL}/discordsh
Headers:
Authorization: Bearer {original_user_jwt}
Content-Type: application/json
Body: { command: "server.submit", ...validated_fields }
5. Parse edge function response
6. Return response to client (pass-through success/error)
Tasks
- Add
EDGE_FUNCTION_URLto environment config (e.g.,https://supabase.kbve.com/functions/v1) - Use existing
reqwestdependency (already in Cargo.toml) for HTTP client - Store a
reqwest::ClientonAppState(connection pooling) - Set a timeout on edge function call (10 seconds)
- Handle edge function errors gracefully (502 if edge function unreachable)
4. Update Frontend to Call Rust
File: apps/discordsh/astro-discordsh/src/lib/servers/discordshEdge.ts
Change submitServer() to call the Rust backend instead of the edge function directly:
// Before:
const EDGE_BASE = 'https://supabase.kbve.com/functions/v1/discordsh';
// After:
const API_BASE = ''; // relative to same origin (axum serves both static + API)
export async function submitServer(params: SubmitServerParams) {
const session = authBridge.getSession();
const res = await fetch('/api/servers/submit', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(session?.access_token && {
Authorization: `Bearer ${session.access_token}`,
}),
},
body: JSON.stringify(params),
});
return res.json();
}Tasks
- Update
submitServer()to call/api/servers/submit - Keep
castVote()calling edge function directly for now (separate scope) - Update error handling to match new error response format (field-level errors)
5. Validation Layer Comparison
Each layer validates independently — if any layer is bypassed (e.g., someone calls the edge function directly), the remaining layers still protect:
| Check | Rust | Edge Fn | Proxy RPC | Service RPC | SQL |
|---|---|---|---|---|---|
| Snowflake format | regex | regex | — | — | CHECK |
| Name safe-text | is_safe_text() |
requireNonEmpty() |
— | — | CHECK + is_safe_text() |
| Summary safe-text | is_safe_text() |
requireNonEmpty() |
— | — | CHECK + is_safe_text() |
| Invite code format | regex | regex | regex | — | CHECK |
| Category bounds | 1-12, max 3 | — | — | — | CHECK + are_valid_categories() |
| Tag format | regex, max 10 | — | — | — | CHECK + are_valid_tags() |
| URL safety | is_safe_url() |
— | — | — | CHECK + is_safe_url() |
| hCaptcha | non-empty | verifyCaptcha() |
— | — | — |
| Auth (JWT) | forward header | parseJwt() |
auth.uid() |
param | RLS |
| Rate limit (IP) | DashMap | — | — | — | — |
| Rate limit (user) | — | — | count query | — | — |
| Duplicate server | — | — | — | SELECT | UNIQUE PK |
| Advisory lock | — | — | — | pg_advisory_xact_lock |
— |
| Timestamp protection | — | — | — | — | trigger |
| Column protection | — | — | — | — | trigger |
6. New Files & Modifications
| File | Action | Description |
|---|---|---|
axum-discordsh/src/api/mod.rs |
Create | API module root |
axum-discordsh/src/api/servers.rs |
Create | Server submission handler + validation |
axum-discordsh/src/api/validate.rs |
Create | Shared validation functions (safe_text, snowflake, etc.) |
axum-discordsh/src/api/rate_limit.rs |
Create | IP-based rate limiter |
axum-discordsh/src/transport/https.rs |
Edit | Mount /api/servers routes |
axum-discordsh/src/state.rs |
Edit | Add reqwest::Client + rate limiter to AppState |
axum-discordsh/src/main.rs |
Edit | Add mod api; |
astro-discordsh/src/lib/servers/discordshEdge.ts |
Edit | Point submitServer() to Rust API |
All Rust paths relative to apps/discordsh/.
7. Environment Variables
New for axum-discordsh
EDGE_FUNCTION_URL=https://supabase.kbve.com/functions/v1This is the only new env var needed — the Rust layer doesn't need database credentials or captcha secrets since it delegates those responsibilities downstream.
8. Testing
- Unit tests — Rust validation functions (
is_safe_text, snowflake regex, category bounds, tag format, URL validation) - Integration test — POST
/api/servers/submitwith valid/invalid payloads against running axum server - Rate limit test — Verify 429 after exceeding submission threshold
- E2e test — Full flow through Rust → Edge → DB (requires running Supabase)
- Existing e2e tests — Verify all existing smoke tests still pass
Acceptance Criteria
-
POST /api/servers/submitvalidates all fields and returns field-level errors - Valid requests are forwarded to the edge function with the user's JWT
- IP-based rate limiting returns 429 with
Retry-Afterheader - Frontend
submitServer()calls the Rust endpoint instead of the edge function - Edge function continues to work as a fallback if called directly (defense in depth)
- Rust safe-text validation matches PostgreSQL
is_safe_text()behavior - All existing e2e tests pass
Metadata
Metadata
Assignees
Labels
Type
Projects
Status