diff --git a/.changeset/validate-form-submitter.md b/.changeset/validate-form-submitter.md new file mode 100644 index 0000000..0407966 --- /dev/null +++ b/.changeset/validate-form-submitter.md @@ -0,0 +1,5 @@ +--- +"superformdata": patch +--- + +Validate `encode(form, { submitter })` submitters before encoding so non-submit controls and controls owned by another form throw like native `FormData(form, submitter)`. diff --git a/src/encode.ts b/src/encode.ts index 279959e..e4f25d9 100644 --- a/src/encode.ts +++ b/src/encode.ts @@ -83,6 +83,27 @@ function isSubmitButton(el: Element): boolean { return false; } +function validateSubmitter(form: HTMLFormElement, submitter?: HTMLElement | null): void { + if (submitter == null) return; + + if (!isSubmitButton(submitter)) { + throw new TypeError("The specified element is not a submit button"); + } + + if (submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) { + if (submitter.form === form) return; + } + + if (typeof DOMException !== "undefined") { + throw new DOMException( + "The specified element is not owned by this form element", + "NotFoundError", + ); + } + + throw new Error("The specified element is not owned by this form element"); +} + function isSubmittableFormControl( element: Element, ): element is HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement { @@ -117,6 +138,8 @@ function encodeForm( explicitTypes?: Record, submitter?: HTMLElement | null, ): EncodedEntry[] | PreservedFileEntry[] { + validateSubmitter(form, submitter); + const entries: PreservedFileEntry[] = []; const types: Record = { ...explicitTypes }; diff --git a/test/client.test.ts b/test/client.test.ts index 0ef6c74..e6e20ec 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -453,18 +453,44 @@ describe("encode(form)", () => { const button = form.querySelector("button")!; const entries = encode(form, { submitter: button }); - expect(entries).toContainEqual(["title", "hello"]); - expect(entries).toContainEqual(["action", "save"]); + expect(entries).toEqual([ + ["title", "hello"], + ["action", "save"], + ]); }); - test("always excludes button[type=button]", () => { + test("rejects button[type=button] passed as submitter", () => { const form = createForm( '', ); const button = form.querySelector("button")!; - const entries = encode(form, { submitter: button }); - expect(entries).toEqual([["title", "hello"]]); + expect(() => encode(form, { submitter: button })).toThrow(TypeError); + }); + + test("rejects non-submit input passed as submitter", () => { + const form = createForm( + '', + ); + const input = form.querySelector('input[name="notSubmit"]')!; + + expect(() => encode(form, { submitter: input })).toThrow(TypeError); + }); + + test("rejects submit buttons not owned by the form", () => { + document.body.innerHTML = + '
' + + '
'; + const form = document.querySelector("#target")!; + const button = document.querySelector("#other button")!; + + try { + encode(form, { submitter: button }); + throw new Error("Expected encode to reject submitter owned by another form"); + } catch (error) { + expect(error).toBeInstanceOf(DOMException); + expect((error as DOMException).name).toBe("NotFoundError"); + } }); test("excludes input[type=submit] without submitter", () => { @@ -483,8 +509,10 @@ describe("encode(form)", () => { const submitInput = form.querySelector('input[type="submit"]')!; const entries = encode(form, { submitter: submitInput }); - expect(entries).toContainEqual(["title", "hello"]); - expect(entries).toContainEqual(["sub", "Send"]); + expect(entries).toEqual([ + ["title", "hello"], + ["sub", "Send"], + ]); }); test("includes input[type=image] when passed as submitter (encodes name+value, not coordinates)", () => {