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