Skip to content

[DISCORDSH] Rust-First Server Submission Pipeline — Belt & Suspenders Validation #7727

@h0lybyte

Description

@h0lybyte

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 on AppState
  • Sliding window: prune entries older than 5 minutes on each check
  • Return 429 Too Many Requests with Retry-After header

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 AppState struct in state.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_URL to environment config (e.g., https://supabase.kbve.com/functions/v1)
  • Use existing reqwest dependency (already in Cargo.toml) for HTTP client
  • Store a reqwest::Client on AppState (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/v1

This 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/submit with 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/submit validates 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-After header
  • 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

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Status

    Theory

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions