Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/young-pans-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: rework internal representation of form value to be `$state`
5 changes: 2 additions & 3 deletions packages/kit/src/runtime/app/server/remote/form.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
deep_set,
normalize_issue,
flatten_issues
} from '../../../form-utils.svelte.js';
} from '../../../form-utils.js';
import { get_cache, run_remote_function } from './shared.js';

/**
Expand Down Expand Up @@ -215,7 +215,6 @@ export function form(validate_or_fn, maybe_fn) {
return create_field_proxy(
{},
() => data?.input ?? {},
() => {},
(path, value) => {
if (data?.submission) {
// don't override a submission
Expand Down Expand Up @@ -318,7 +317,7 @@ function handle_issues(output, issues, is_remote_request, form_data) {

if (is_array) key = key.slice(0, -2);

output.input = set_nested_value(
set_nested_value(
/** @type {Record<string, any>} */ (output.input),
key,
is_array ? values : values[0]
Expand Down
67 changes: 7 additions & 60 deletions packages/kit/src/runtime/client/remote-functions/form.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ import {
deep_set,
set_nested_value,
throw_on_old_property_access,
split_path,
build_path_string,
normalize_issue
} from '../../form-utils.svelte.js';
} from '../../form-utils.js';

/**
* Merge client issues into server issues. Server issues are persisted unless
Expand Down Expand Up @@ -60,24 +59,9 @@ export function form(id) {
const action = '?/remote=' + encodeURIComponent(action_id);

/**
* By making this $state.raw() and creating a new object each time we update it,
* all consumers along the update chain are properly invalidated.
* @type {Record<string, string | string[] | File | File[]>}
*/
let input = $state.raw({});

// TODO 3.0: Remove versions state and related logic; it's a workaround for $derived not updating when created inside $effects
/**
* This allows us to update individual fields granularly
* @type {Record<string, number>}
*/
const versions = $state({});

/**
* This ensures that `{field.value()}` is updated even if the version hasn't been initialized
* @type {Set<string>}
*/
const version_reads = new Set();
let input = $state({});

/** @type {InternalRemoteFormIssue[]} */
let raw_issues = $state.raw([]);
Expand All @@ -101,16 +85,6 @@ export function form(id) {

let submitted = false;

function update_all_versions() {
for (const path of version_reads) {
versions[path] ??= 0;
}

for (const key of Object.keys(versions)) {
versions[key] += 1;
}
}

/**
* @param {FormData} form_data
* @returns {Record<string, any>}
Expand Down Expand Up @@ -235,8 +209,6 @@ export function form(id) {
if (issues.$) {
release_overrides(updates);
} else {
update_all_versions();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't quite understand why that was necessary here; no test fails without it


if (form_result.refreshes) {
refresh_queries(form_result.refreshes, updates);
} else {
Expand Down Expand Up @@ -386,7 +358,7 @@ export function form(id) {
}
}

input = set_nested_value(input, name, value);
set_nested_value(input, name, value);
} else if (is_file) {
if (DEV && element.multiple) {
throw new Error(
Expand All @@ -397,7 +369,7 @@ export function form(id) {
const file = /** @type {HTMLInputElement & { files: FileList }} */ (element).files[0];

if (file) {
input = set_nested_value(input, name, file);
set_nested_value(input, name, file);
} else {
// Remove the property by setting to undefined and clean up
const path_parts = name.split(/\.|\[|\]/).filter(Boolean);
Expand All @@ -409,7 +381,7 @@ export function form(id) {
delete current[path_parts[path_parts.length - 1]];
}
} else {
input = set_nested_value(
set_nested_value(
input,
name,
element.type === 'checkbox' && !element.checked ? null : element.value
Expand All @@ -418,17 +390,7 @@ export function form(id) {

name = name.replace(/^[nb]:/, '');

versions[name] ??= 0;
versions[name] += 1;

const path = split_path(name);

while (path.pop() !== undefined) {
const name = build_path_string(path);

versions[name] ??= 0;
versions[name] += 1;
}
touched[name] = true;
});

form.addEventListener('reset', async () => {
Expand All @@ -437,7 +399,6 @@ export function form(id) {
await tick();

input = convert_formdata(new FormData(form));
update_all_versions();
});

return () => {
Expand Down Expand Up @@ -530,28 +491,14 @@ export function form(id) {
create_field_proxy(
{},
() => input,
(path) => {
version_reads.add(path);
versions[path];
},
(path, value) => {
if (path.length === 0) {
input = value;
update_all_versions();
} else {
input = deep_set(input, path.map(String), value);
deep_set(input, path.map(String), value);

const key = build_path_string(path);
versions[key] ??= 0;
versions[key] += 1;
touched[key] = true;

const parent_path = path.slice();
while (parent_path.pop() !== undefined) {
const parent_key = build_path_string(parent_path);
versions[parent_key] ??= 0;
versions[parent_key] += 1;
}
}
},
() => issues
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,9 @@
/** @import { StandardSchemaV1 } from '@standard-schema/spec' */

import { DEV } from 'esm-env';
import * as svelte from 'svelte';
// Svelte 4 and under don't have `untrack` - you'll not be able to use remote functions with Svelte 4 but this will still be loaded
const untrack = svelte.untrack ?? ((value) => value());

/**
* Sets a value in a nested object using a path string, not mutating the original object but returning a new object
* Sets a value in a nested object using a path string, mutating the original object
* @param {Record<string, any>} object
* @param {string} path_string
* @param {any} value
Expand All @@ -22,7 +19,7 @@ export function set_nested_value(object, path_string, value) {
value = value === 'on';
}

return deep_set(object, split_path(path_string), value);
deep_set(object, split_path(path_string), value);
}

/**
Expand All @@ -31,7 +28,7 @@ export function set_nested_value(object, path_string, value) {
*/
export function convert_formdata(data) {
/** @type {Record<string, any>} */
let result = Object.create(null); // guard against prototype pollution
const result = {};

for (let key of data.keys()) {
if (key.startsWith('sveltekit:')) {
Expand Down Expand Up @@ -61,7 +58,7 @@ export function convert_formdata(data) {
values = values.map((v) => v === 'on');
}

result = set_nested_value(result, key, is_array ? values : values[0]);
set_nested_value(result, key, is_array ? values : values[0]);
}

return result;
Expand All @@ -81,18 +78,32 @@ export function split_path(path) {
}

/**
* Sets a value in a nested object using an array of keys.
* Does not mutate the original object; returns a new object.
* Check if a property key is dangerous and could lead to prototype pollution
* @param {string} key
*/
function check_prototype_pollution(key) {
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
throw new Error(
`Invalid key "${key}"` +
(DEV ? ': This key is not allowed to prevent prototype pollution.' : '')
);
}
}

/**
* Sets a value in a nested object using an array of keys, mutating the original object.
* @param {Record<string, any>} object
* @param {string[]} keys
* @param {any} value
*/
export function deep_set(object, keys, value) {
const result = Object.assign(Object.create(null), object); // guard against prototype pollution
let current = result;
let current = object;

for (let i = 0; i < keys.length - 1; i += 1) {
const key = keys[i];

check_prototype_pollution(key);

const is_array = /^\d+$/.test(keys[i + 1]);
const exists = key in current;
const inner = current[key];
Expand All @@ -101,18 +112,16 @@ export function deep_set(object, keys, value) {
throw new Error(`Invalid array key ${keys[i + 1]}`);
}

current[key] = is_array
? exists
? [...inner]
: []
: // guard against prototype pollution
Object.assign(Object.create(null), inner);
if (!exists) {
current[key] = is_array ? [] : {};
}

current = current[key];
}

current[keys[keys.length - 1]] = value;
return result;
const final_key = keys[keys.length - 1];
check_prototype_pollution(final_key);
current[final_key] = value;
}

/**
Expand Down Expand Up @@ -196,18 +205,14 @@ export function deep_get(object, path) {
* Creates a proxy-based field accessor for form data
* @param {any} target - Function or empty POJO
* @param {() => Record<string, any>} get_input - Function to get current input data
* @param {(path: string) => void} depend - Function to make an effect depend on a specific field
* @param {(path: (string | number)[], value: any) => void} set_input - Function to set input data
* @param {() => Record<string, InternalRemoteFormIssue[]>} get_issues - Function to get current issues
* @param {(string | number)[]} path - Current access path
* @returns {any} Proxy object with name(), value(), and issues() methods
*/
export function create_field_proxy(target, get_input, depend, set_input, get_issues, path = []) {
const path_string = build_path_string(path);

export function create_field_proxy(target, get_input, set_input, get_issues, path = []) {
const get_value = () => {
depend(path_string);
return untrack(() => deep_get(get_input(), path));
return deep_get(get_input(), path);
};

return new Proxy(target, {
Expand All @@ -216,7 +221,7 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss

// Handle array access like jobs[0]
if (/^\d+$/.test(prop)) {
return create_field_proxy({}, get_input, depend, set_input, get_issues, [
return create_field_proxy({}, get_input, set_input, get_issues, [
...path,
parseInt(prop, 10)
]);
Expand All @@ -229,17 +234,11 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss
set_input(path, newValue);
return newValue;
};
return create_field_proxy(set_func, get_input, depend, set_input, get_issues, [
...path,
prop
]);
return create_field_proxy(set_func, get_input, set_input, get_issues, [...path, prop]);
}

if (prop === 'value') {
return create_field_proxy(get_value, get_input, depend, set_input, get_issues, [
...path,
prop
]);
return create_field_proxy(get_value, get_input, set_input, get_issues, [...path, prop]);
}

if (prop === 'issues' || prop === 'allIssues') {
Expand All @@ -259,10 +258,7 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss
}));
};

return create_field_proxy(issues_func, get_input, depend, set_input, get_issues, [
...path,
prop
]);
return create_field_proxy(issues_func, get_input, set_input, get_issues, [...path, prop]);
}

if (prop === 'as') {
Expand Down Expand Up @@ -411,14 +407,11 @@ export function create_field_proxy(target, get_input, depend, set_input, get_iss
});
};

return create_field_proxy(as_func, get_input, depend, set_input, get_issues, [
...path,
'as'
]);
return create_field_proxy(as_func, get_input, set_input, get_issues, [...path, 'as']);
}

// Handle property access (nested fields)
return create_field_proxy({}, get_input, depend, set_input, get_issues, [...path, prop]);
return create_field_proxy({}, get_input, set_input, get_issues, [...path, prop]);
}
});
}
Expand Down
18 changes: 17 additions & 1 deletion packages/kit/src/runtime/form-utils.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { convert_formdata, split_path } from './form-utils.svelte.js';
import { convert_formdata, split_path } from './form-utils.js';

describe('split_path', () => {
const good = [
Expand Down Expand Up @@ -73,4 +73,20 @@ describe('convert_formdata', () => {
}
});
});

const pollution_attacks = [
'__proto__.polluted',
'constructor.polluted',
'prototype.polluted',
'user.__proto__.polluted',
'user.constructor.polluted'
];

for (const attack of pollution_attacks) {
test(`prevents prototype pollution: ${attack}`, () => {
const data = new FormData();
data.append(attack, 'bad');
expect(() => convert_formdata(data)).toThrow(/Invalid key "/);
});
}
});