From 99f2de3bee60e2cbb2117edd4290cf2aa7dc8bb1 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:49:33 +0300 Subject: [PATCH] Stricter URL widget validation --- app/form/fields.test.ts | 29 +++++++++++++++++++++++++++++ app/form/fields.ts | 21 ++++++++++++++------- 2 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 app/form/fields.test.ts diff --git a/app/form/fields.test.ts b/app/form/fields.test.ts new file mode 100644 index 000000000..692b16ce2 --- /dev/null +++ b/app/form/fields.test.ts @@ -0,0 +1,29 @@ +import * as v from "valibot"; +import { describe, expect, test } from "vitest"; +import { textField, textFieldOptional } from "./fields"; + +describe("textField", () => { + const schema = textField({ validate: "url", maxLength: 150 }); + + test.each([ + ["https://sendou.ink", true, "https URL"], + ["http://sendou.ink", true, "http URL"], + ["javascript:alert(1)", false, "javascript URL"], + ["JavaScript:alert(1)", false, "javascript URL with mixed case protocol"], + ["data:text/html,", false, "data URL"], + ["not a url", false, "not a URL at all"], + ])("%s -> %s (%s)", (input, expected) => { + expect(v.safeParse(schema, input).success).toBe(expected); + }); +}); + +describe("textFieldOptional", () => { + const schema = textFieldOptional({ validate: "url", maxLength: 150 }); + + test.each([ + ["https://sendou.ink", true, "https URL"], + ["javascript:alert(1)", false, "javascript URL"], + ])("%s -> %s (%s)", (input, expected) => { + expect(v.safeParse(schema, input).success).toBe(expected); + }); +}); diff --git a/app/form/fields.ts b/app/form/fields.ts index 04fbcb08b..bd81b7377 100644 --- a/app/form/fields.ts +++ b/app/form/fields.ts @@ -40,6 +40,18 @@ import type { TrophyOption, } from "./types"; +const httpUrlSchema = v.pipe( + v.string(), + v.url(), + v.check((value) => { + try { + return ["http:", "https:"].includes(new URL(value).protocol); + } catch { + return false; + } + }, "Only http(s) URLs are allowed."), +); + export const formRegistry = new WeakMap(); /** Clones the schema first so shared instances (e.g. `id`, `stageId`) each get their own registry entry. */ @@ -155,12 +167,7 @@ export function textFieldOptional( ): v.GenericSchema { // validated as a plain string, so unlike other optional text fields it has no null fallback and its key stays required if (args.validate === "url") { - return registerTextField( - v.pipe(v.string(), v.url()), - args, - false, - false, - ) as never; + return registerTextField(httpUrlSchema, args, false, false) as never; } return registerTextField( @@ -174,7 +181,7 @@ export function textFieldOptional( export function textField(args: TextFieldArgs): v.GenericSchema { const schema = args.validate === "url" - ? v.pipe(v.string(), v.url()) + ? httpUrlSchema : safeStringSchema({ min: args.minLength, max: args.maxLength }); return registerTextField(schema, args, true, false) as never;