From a10012df465bdbc93cd8e88f18c20617e574c095 Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Tue, 21 Apr 2026 16:30:52 +0545 Subject: [PATCH] chore: strict Subdomain Validation with Detailed Error Messages --- backend/src/api.rs | 158 ++++++++++++++++-------- frontend/src/components/RelayForm.tsx | 7 ++ frontend/src/lib/subdomain.ts | 22 ++++ frontend/src/pages/relays/RelayEdit.tsx | 3 +- 4 files changed, 137 insertions(+), 53 deletions(-) create mode 100644 frontend/src/lib/subdomain.ts diff --git a/backend/src/api.rs b/backend/src/api.rs index 8895648..b5a6cfb 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -251,22 +251,16 @@ impl Api { } } - fn prepare_relay(&self, mut relay: Relay) -> anyhow::Result { - if !relay - .subdomain - .chars() - .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - { - return Err(anyhow!("invalid-subdomain")); - } + fn prepare_relay(&self, mut relay: Relay) -> std::result::Result { + validate_subdomain_label(&relay.subdomain)?; - let plan = Query::get_plan(&relay.plan).ok_or_else(|| anyhow!("invalid-plan"))?; + let plan = Query::get_plan(&relay.plan).ok_or(RelayValidationError::InvalidPlan)?; if !plan.blossom && relay.blossom_enabled == 1 { - return Err(anyhow!("premium-feature")); + return Err(RelayValidationError::PremiumFeature); } if !plan.livekit && relay.livekit_enabled == 1 { - return Err(anyhow!("premium-feature")); + return Err(RelayValidationError::PremiumFeature); } if relay.schema.is_empty() { @@ -289,6 +283,96 @@ impl Api { } } +const SUBDOMAIN_LABEL_MAX_LEN: usize = 63; +const RESERVED_SUBDOMAIN_LABELS: [&str; 2] = ["api", "admin"]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubdomainValidationError { + Empty, + TooLong, + Reserved, + EdgeHyphen, + InvalidCharacters, +} + +impl SubdomainValidationError { + fn code(self) -> &'static str { + match self { + Self::Empty => "subdomain-empty", + Self::TooLong => "subdomain-too-long", + Self::Reserved => "subdomain-reserved", + Self::EdgeHyphen => "subdomain-invalid-hyphen", + Self::InvalidCharacters => "subdomain-invalid-characters", + } + } + + fn message(self) -> &'static str { + match self { + Self::Empty => "subdomain is required", + Self::TooLong => "subdomain must be 63 characters or fewer", + Self::Reserved => "subdomain is reserved", + Self::EdgeHyphen => "subdomain cannot start or end with a hyphen", + Self::InvalidCharacters => { + "subdomain may only contain lowercase letters, numbers, and hyphens" + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RelayValidationError { + InvalidPlan, + PremiumFeature, + Subdomain(SubdomainValidationError), +} + +impl RelayValidationError { + fn code(self) -> &'static str { + match self { + Self::InvalidPlan => "invalid-plan", + Self::PremiumFeature => "premium-feature", + Self::Subdomain(reason) => reason.code(), + } + } + + fn message(self) -> &'static str { + match self { + Self::InvalidPlan => "plan not found", + Self::PremiumFeature => "feature requires a paid plan", + Self::Subdomain(reason) => reason.message(), + } + } +} + +impl From for RelayValidationError { + fn from(value: SubdomainValidationError) -> Self { + Self::Subdomain(value) + } +} + +fn validate_subdomain_label(subdomain: &str) -> std::result::Result<(), SubdomainValidationError> { + if subdomain.is_empty() { + return Err(SubdomainValidationError::Empty); + } + if subdomain.len() > SUBDOMAIN_LABEL_MAX_LEN { + return Err(SubdomainValidationError::TooLong); + } + if subdomain.starts_with('-') || subdomain.ends_with('-') { + return Err(SubdomainValidationError::EdgeHyphen); + } + if RESERVED_SUBDOMAIN_LABELS.contains(&subdomain) { + return Err(SubdomainValidationError::Reserved); + } + if !subdomain + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + { + return Err(SubdomainValidationError::InvalidCharacters); + } + + Ok(()) +} + fn ok(status: StatusCode, data: T) -> Response { (status, Json(OkResponse { data, code: "ok" })).into_response() } @@ -316,6 +400,14 @@ fn parse_bool_default(value: i64, default: i64) -> i64 { } } +fn relay_validation_error_response(error: RelayValidationError) -> Response { + err( + StatusCode::UNPROCESSABLE_ENTITY, + error.code(), + error.message(), + ) +} + fn map_unique_error(err: &anyhow::Error) -> Option<&'static str> { let sqlx_err = err.downcast_ref::()?; let sqlx::Error::Database(db_err) = sqlx_err else { @@ -608,27 +700,9 @@ async fn create_relay( }; relay = match state.api.prepare_relay(relay) { - Err(e) if e.to_string() == "invalid-plan" => { - return Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "invalid-plan", - "plan not found", - )); - } Ok(r) => r, - Err(e) if e.to_string() == "premium-feature" => { - return Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "premium-feature", - "feature requires a paid plan", - )); - } - Err(_) => { - return Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "invalid-relay", - "relay validation failed", - )); + Err(e) => { + return Ok(relay_validation_error_response(e)); } }; @@ -712,27 +786,9 @@ async fn update_relay( } relay = match state.api.prepare_relay(relay) { - Err(e) if e.to_string() == "invalid-plan" => { - return Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "invalid-plan", - "plan not found", - )); - } Ok(r) => r, - Err(e) if e.to_string() == "premium-feature" => { - return Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "premium-feature", - "feature requires a paid plan", - )); - } - Err(_) => { - return Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "invalid-relay", - "relay validation failed", - )); + Err(e) => { + return Ok(relay_validation_error_response(e)); } }; diff --git a/frontend/src/components/RelayForm.tsx b/frontend/src/components/RelayForm.tsx index 173c7ed..3b8aa35 100644 --- a/frontend/src/components/RelayForm.tsx +++ b/frontend/src/components/RelayForm.tsx @@ -1,6 +1,7 @@ import { createEffect, createMemo, createSignal, For } from "solid-js" import type { Relay } from "@/lib/hooks" import { slugify } from "@/lib/slugify" +import { validateSubdomainLabel } from "@/lib/subdomain" import { setToastMessage } from "@/components/Toast" import { plans } from "@/lib/state" @@ -31,6 +32,12 @@ export default function RelayForm(props: RelayFormProps) { return } + const subdomainError = validateSubdomainLabel(subdomain()) + if (subdomainError) { + setToastMessage(subdomainError) + return + } + setToastMessage("") setSubmitting(true) diff --git a/frontend/src/lib/subdomain.ts b/frontend/src/lib/subdomain.ts new file mode 100644 index 0000000..46a43e6 --- /dev/null +++ b/frontend/src/lib/subdomain.ts @@ -0,0 +1,22 @@ +const SUBDOMAIN_LABEL_MAX_LEN = 63 +const RESERVED_SUBDOMAIN_LABELS = new Set(["api", "admin"]) + +export function validateSubdomainLabel(subdomain: string): string | null { + if (subdomain.length === 0) { + return "subdomain is required" + } + if (subdomain.length > SUBDOMAIN_LABEL_MAX_LEN) { + return "subdomain must be 63 characters or fewer" + } + if (subdomain.startsWith("-") || subdomain.endsWith("-")) { + return "subdomain cannot start or end with a hyphen" + } + if (RESERVED_SUBDOMAIN_LABELS.has(subdomain)) { + return "subdomain is reserved" + } + if (!/^[a-z0-9-]+$/.test(subdomain)) { + return "subdomain may only contain lowercase letters, numbers, and hyphens" + } + + return null +} diff --git a/frontend/src/pages/relays/RelayEdit.tsx b/frontend/src/pages/relays/RelayEdit.tsx index 1b50016..75af1d2 100644 --- a/frontend/src/pages/relays/RelayEdit.tsx +++ b/frontend/src/pages/relays/RelayEdit.tsx @@ -1,7 +1,6 @@ import { useNavigate, useParams } from "@solidjs/router" import { Show } from "solid-js" import RelayForm, { type RelayFormValues } from "@/components/RelayForm" -import { slugify } from "@/lib/slugify" import BackLink from "@/components/BackLink" import PageContainer from "@/components/PageContainer" import ResourceState from "@/components/ResourceState" @@ -18,7 +17,7 @@ export default function RelayEdit(props: { basePath?: string; title?: string }) async function handleSubmit(values: RelayFormValues) { await updateRelayById(relayId(), { - subdomain: slugify(values.subdomain), + subdomain: values.subdomain, info_name: values.info_name.trim(), info_icon: values.info_icon.trim(), info_description: values.info_description.trim(), -- 2.52.0