Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add functionality to override http methods (issue #1046) #2989

Merged
merged 11 commits into from
Jan 11, 2022
6 changes: 6 additions & 0 deletions .changeset/chilly-moose-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sveltejs/kit': patch
'create-svelte': patch
---

[feat] add functionality to override http methods (issue #1046)
39 changes: 39 additions & 0 deletions documentation/docs/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,45 @@ The `body` property of the request object will be provided in the case of POST r
- Form data (with content-type `application/x-www-form-urlencoded` or `multipart/form-data`) will be parsed to a read-only version of the [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object.
- All other data will be provided as a `Uint8Array`

#### HTTP Method Overrides

Given that the only valid `<form>` methods are GET and POST, functionality is provided to override this limitation and allow the use of other HTTP verbs. This is particularly helpful when ensuring your application works even when javascript fails or is disabled.

- Disabled by default to prevent unintended behavior for those who don't need it
benmccann marked this conversation as resolved.
Show resolved Hide resolved
- For security purposes, the original method on the form must be `POST` and cannot be overridden with `GET`
Copy link
Member

Choose a reason for hiding this comment

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

this is a bit vague. it'd be better if we can say something more specific

Suggested change
- For security purposes, the original method on the form must be `POST` and cannot be overridden with `GET`
- To protect against CSRF attacks, the original method on the form must be `POST` and cannot be overridden with `GET`

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Although I'm not sure if this is for CSRF protection or for protection against other attack vectors (cache poisoning was the example given: #1046 (comment)).

Unfortunately I lack expertise in this field. Maybe @Prinzhorn could approve your suggestion or provide a more detailed/accurate statement?

Choose a reason for hiding this comment

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

I don't think we need to be that specific and it's also not just a security thing. GET has vastly different semantics from POST, PUT etc. We should focus on why the feature exist, what problem it solves. I don't see any problem being solved by changing the method from/to GET.

I'd shorten the paragraph and the two bullet points to something like:


In contrast to fetch the only valid methods for a <form> are GET and POST. Svelte allows you to override the <form> method to workaround this limitation if you need to. That way your application can transparently work when JavaScript fails or is disabled by using fetch and <form> interchangeably with the same endpoint.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like your version of why this feature exists but I'm unsure about removing those 2 bullet points. It should be pointed out that the feature needs to be enabled by the user. The defaults are noted later in the configuration section but they might not notice that. And explaining that the form method must be POST and cannot be overridden with GET will prevent users from using this feature incorrectly and forcing them to figure out what is wrong when they receive build errors or their app doesn't function as expected.

Choose a reason for hiding this comment

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

I agree about documenting everything. But for me docs are a last resort (the actual text, anything beyond skimming through code examples) when things already went wrong and I need to figure out why. But we can do much better. During dev when we see _method and methodOverride is disabled we can tell the developer that it needs to be enabled. Same if we see _method with something other than POST. Same for all other cases that are currently silently ignored. If the method is not in allowedMethods arguable it should even return a 400 in production. Or at the very least during dev it should tell you. We have the knowledge, let's not make the user run into unexpected behavior (e.g. silently ignoring _method for GET). Instead let's fail as loud as possible so they don't need to open their browser to actually read the docs or search though /issues. Let them stay in the zone and be like "oh, I need to set enabled: true, gotcha, thanks friendly error message".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agreed. Can anyone tell me how compile/build warnings are generated in Sveltekit?

And I'm on the fence regarding returning a 400 if the method isn't in allowed methods, but I agree silently ignoring it is probably not the correct action. I'd like to handle it similar to other scenarios in Sveltekit.

- There are 2 different ways to specify a method override strategy
am283721 marked this conversation as resolved.
Show resolved Hide resolved
- `url_parameter`: Pass the key and desired method as a query string
- `form_data`: Pass as a field within the form where the field name is the key and the field value is the desired method
- `strategy: 'both'` will enable both methods (`url_parameter` takes precendence in the event that both strategies are used within the same form)

```js
// svelte.config.js
export default {
kit: {
methodOverride: {
enabled: true,
key: '_method',
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
strategy: 'both'
},
}
};
```

```html
<form method="post" action="/todos/{id}?_method=put">
am283721 marked this conversation as resolved.
Show resolved Hide resolved
<!-- form elements -->
</form>
```

OR

```html
<form method="post" action="/todos/{id}">
<input type="hidden" name="_method" value="PUT">
</form>
```

### Private modules

A filename that has a segment with a leading underscore, such as `src/routes/foo/_Private.svelte` or `src/routes/bar/_utils/cool-util.js`, is hidden from the router, but can be imported by files that are not.
Expand Down
19 changes: 19 additions & 0 deletions documentation/docs/14-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ const config = {
host: null,
hostHeader: null,
hydrate: true,
methodOverride: {
enabled: false,
key: '_method',
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
strategy: 'both'
},
package: {
dir: 'package',
emitTypes: true,
Expand Down Expand Up @@ -124,6 +130,19 @@ export default {

Whether to [hydrate](#ssr-and-javascript-hydrate) the server-rendered HTML with a client-side app. (It's rare that you would set this to `false` on an app-wide basis.)

### methodOverride

See [HTTP Method Overrides](#routing-endpoints-http-method-overrides). An object containing zero or more of the following:

- `enabled` — set to `true` to enable method overriding
- `key` — query parameter name/field name to use for passing the intended method value
- `allowedMethods` - array of HTTP methods that can be used when overriding the original request method
- `strategy`

- `'both'` — (default) will look for the override key in both the list of query parameters and the form fields
- `'url_parameter'` — only allow overriding via a query parameter
- `form_data` — only allow overriding via a form field
am283721 marked this conversation as resolved.
Show resolved Hide resolved

### package

Options related to [creating a package](#packaging).
Expand Down
5 changes: 0 additions & 5 deletions packages/create-svelte/templates/default/src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,6 @@ export const handle: Handle = async ({ request, resolve }) => {
const cookies = cookie.parse(request.headers.cookie || '');
request.locals.userid = cookies.userid || uuid();

// TODO https://github.com/sveltejs/kit/issues/1046
if (request.query.has('_method')) {
request.method = request.query.get('_method').toUpperCase();
}

const response = await resolve(request);

if (!cookies.userid) {
Expand Down
7 changes: 6 additions & 1 deletion packages/create-svelte/templates/default/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@ const config = {
adapter: adapter(),

// hydrate the <div id="svelte"> element in src/app.html
target: '#svelte'
target: '#svelte',

// Override http methods in the Todo forms
methodOverride: {
enabled: true
}
}
};

Expand Down
6 changes: 6 additions & 0 deletions packages/kit/src/core/build/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,12 @@ async function build_server(
initiator: undefined,
load_component,
manifest,
methodOverride: {
enabled: ${config.kit.methodOverride.enabled},
key: ${s(config.kit.methodOverride.key)},
allowedMethods: [${config.kit.methodOverride.allowedMethods.map(method => s(method))}],
strategy: ${s(config.kit.methodOverride.strategy)}
},
paths: settings.paths,
prerender: ${config.kit.prerender.enabled},
read: settings.read,
Expand Down
12 changes: 12 additions & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ test('fills in defaults', () => {
host: null,
hostHeader: null,
hydrate: true,
methodOverride: {
enabled: false,
key: '_method',
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
strategy: 'both'
},
package: {
dir: 'package',
emitTypes: true
Expand Down Expand Up @@ -132,6 +138,12 @@ test('fills in partial blanks', () => {
host: null,
hostHeader: null,
hydrate: true,
methodOverride: {
enabled: false,
key: '_method',
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
strategy: 'both'
},
package: {
dir: 'package',
emitTypes: true
Expand Down
16 changes: 16 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ const options = object(

hydrate: boolean(true),

methodOverride: object({
enabled: boolean(false),
key: string('_method'),
allowedMethods: validate(['PUT', 'PATCH', 'DELETE'], (input, keypath) => {
if (!Array.isArray(input) || !input.every((method) => typeof method === 'string')) {
throw new Error(`${keypath} must be an array of strings`);
}

return input;
}),
strategy: validate('both', (input, keypath) => {
if (['both', 'url_parameter', 'form_data'].includes(input)) return input;
throw new Error(`${keypath} must be either "both", "url_parameter" or "form_data"`);
})
}),

package: object({
dir: string('package'),
// excludes all .d.ts and filename starting with _
Expand Down
6 changes: 6 additions & 0 deletions packages/kit/src/core/config/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ async function testLoadDefaultConfig(path) {
host: null,
hostHeader: null,
hydrate: true,
methodOverride: {
enabled: false,
key: '_method',
allowedMethods: ['PUT', 'PATCH', 'DELETE'],
strategy: 'both'
},
package: {
dir: 'package',
emitTypes: true
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ async function create_plugin(config, dir, cwd, get_manifest) {
},
hooks,
hydrate: config.kit.hydrate,
methodOverride: config.kit.methodOverride,
paths: {
base: config.kit.paths.base,
assets: config.kit.paths.assets ? SVELTE_KIT_ASSETS : config.kit.paths.base
Expand Down
26 changes: 26 additions & 0 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { lowercase_keys } from './utils.js';
import { hash } from '../hash.js';
import { get_single_valued_header } from '../../utils/http.js';
import { coalesce_to_error } from '../../utils/error.js';
import { ReadOnlyFormData } from './parse_body/read_only_form_data.js';

/** @type {import('@sveltejs/kit/ssr').Respond} */
export async function respond(incoming, options, state = {}) {
Expand Down Expand Up @@ -40,6 +41,31 @@ export async function respond(incoming, options, state = {}) {
locals: {}
};

if (options.methodOverride.enabled && request.method.toUpperCase() === 'POST') {
const { strategy = '', key: method_key = '', allowedMethods = [] } = options.methodOverride;
let new_request_method;

if (
['both', 'form_data'].includes(strategy) &&
request.body instanceof ReadOnlyFormData &&
request.body.has(method_key)
) {
new_request_method = (request.body.get(method_key) || request.method).toUpperCase();
}

if (['both', 'url_parameter'].includes(strategy) && incoming.query.has(method_key)) {
new_request_method = (incoming.query.get(method_key) || request.method).toUpperCase();
am283721 marked this conversation as resolved.
Show resolved Hide resolved
}

if (
new_request_method &&
allowedMethods.includes(new_request_method) &&
new_request_method !== 'GET'
am283721 marked this conversation as resolved.
Show resolved Hide resolved
) {
request.method = new_request_method;
}
}

try {
return await options.hooks.handle({
request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function read_only_form_data() {
};
}

class ReadOnlyFormData {
export class ReadOnlyFormData {
/** @type {Map<string, string[]>} */
#map;

Expand Down
54 changes: 54 additions & 0 deletions packages/kit/test/apps/basics/src/routes/method-override/_tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as assert from 'uvu/assert';

/** @type {import('test').TestMaker} */
export default function (test) {
test('http method is overridden via URL parameter', '/method-override', async ({ page }) => {
let val;

// Check initial value
val = await page.textContent('h1');
assert.equal('', val);

await page.click('"PATCH"');
val = await page.textContent('h1');
assert.equal('PATCH', val);

await page.click('"DELETE"');
val = await page.textContent('h1');
assert.equal('DELETE', val);
});

test('GET method is not overridden', '/method-override', async ({ page }) => {
await page.click('"No Override From GET"');
const val = await page.textContent('h1');
assert.equal('GET', val);
});

test('POST method is not overridden with GET', '/method-override', async ({ page }) => {
await page.click('"No Override To GET"');
const val = await page.textContent('h1');
assert.equal('POST', val);
});

test('http method is overridden via hidden input', '/method-override', async ({ page }) => {
await page.click('"PATCH Via Hidden Input"');
const val = await page.textContent('h1');
assert.equal('PATCH', val);
});

test('GET method is not overridden via hidden input', '/method-override', async ({ page }) => {
await page.click('"No Override From GET Via Hidden Input"');
const val = await page.textContent('h1');
assert.equal('GET', val);
});

test(
'POST method is not overridden with GET via hidden input',
'/method-override',
async ({ page }) => {
await page.click('"No Override To GET Via Hidden Input"');
const val = await page.textContent('h1');
assert.equal('POST', val);
}
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const buildResponse = (/** @type {string} */ method) => ({
status: 303,
headers: {
location: `/method-override?method=${method}`
}
});

/** @type {import('@sveltejs/kit').RequestHandler} */
export const get = (request) => {
return buildResponse(request.method);
};

/** @type {import('@sveltejs/kit').RequestHandler} */
export const post = (request) => {
return buildResponse(request.method);
};

/** @type {import('@sveltejs/kit').RequestHandler} */
export const patch = (request) => {
return buildResponse(request.method);
};

/** @type {import('@sveltejs/kit').RequestHandler} */
export const del = (request) => {
return buildResponse(request.method);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<script context="module">
/** @type {import('@sveltejs/kit').Load} */
export async function load({ page }) {
return {
props: {
method: page.query.get('method') || ''
}
};
}
</script>

<script>
/** @type {string} */
export let method;
</script>

<h1>{method}</h1>

<form action="/method-override/fetch.json?_method=PATCH" method="POST">
<input name="methodoverride" />
<button>PATCH</button>
</form>

<form action="/method-override/fetch.json?_method=DELETE" method="POST">
<input name="methodoverride" />
<button>DELETE</button>
</form>

<form action="/method-override/fetch.json?_method=POST" method="GET">
<input name="methodoverride" />
<button>No Override From GET</button>
</form>

<form action="/method-override/fetch.json?_method=GET" method="POST">
<input name="methodoverride4" />
<button>No Override To GET</button>
</form>

<form action="/method-override/fetch.json" method="POST">
<input type="hidden" name="_method" value="PATCH" />
<button>PATCH Via Hidden Input</button>
</form>

<form action="/method-override/fetch.json" method="GET">
<input type="hidden" name="_method" value="POST" />
<button>No Override From GET Via Hidden Input</button>
</form>

<form action="/method-override/fetch.json" method="POST">
<input type="hidden" name="_method" value="GET" />
<button>No Override To GET Via Hidden Input</button>
</form>
3 changes: 3 additions & 0 deletions packages/kit/test/apps/basics/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ const config = {
// the reload confuses Playwright
include: ['cookie', 'marked']
}
},
methodOverride: {
enabled: true
}
}
};
Expand Down
Loading