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

Make sure to handle query parsing correctly on client/server #12154

Closed
wants to merge 13 commits into from
Closed
4 changes: 4 additions & 0 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ function getOptimizedAliases(isServer: boolean): { [pkg: string]: string } {

// Replace: full URL polyfill with platform-based polyfill
url: require.resolve('native-url'),

// use a patched version of querystring that allows using
// custom decodeURIComponent logic for handling invalid values
querystring: require.resolve('../client/querystring'),
}
)
}
Expand Down
12 changes: 11 additions & 1 deletion packages/next/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,23 @@ class Container extends React.Component {
(isDynamicRoute(router.pathname) || location.search)) ||
(props.__N_SSG && location.search))
) {
const parsedQuery = parseQs(location.search.substr(1), '&', '=', {
decodeURIComponent: str => {
try {
return decodeURIComponent(str)
} catch (err) {
return str
}
},
})

// update query on mount for exported pages
router.replace(
router.pathname +
'?' +
stringifyQs({
...router.query,
...parseQs(location.search.substr(1)),
...parsedQuery,
}),
asPath,
{
Expand Down
161 changes: 161 additions & 0 deletions packages/next/client/querystring.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// Copyright Joyent, Inc. and other Node contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the
// "Software"), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to permit
// persons to whom the Software is furnished to do so, subject to the
// following conditions:
//
// The above copyright notice and this permission notice shall be included
// in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
// USE OR OTHER DEALINGS IN THE SOFTWARE.

// If obj.hasOwnProperty has been overridden, then calling
// obj.hasOwnProperty(prop) will break.
// See: https://github.com/joyent/node/issues/1707

// This is a modified version of querystring-es3 to allow passing
// an option for custom decodeURIComponent logic which matches
// the built-in node options https://nodejs.org/api/querystring.html#querystring_querystring_parse_str_sep_eq_options

function hasOwnProperty(obj, prop) {
return Object.prototype.hasOwnProperty.call(obj, prop)
}

var stringifyPrimitive = function(v) {
switch (typeof v) {
case 'string':
return v

case 'boolean':
return v ? 'true' : 'false'

case 'number':
return isFinite(v) ? v : ''

default:
return ''
}
}

export function encode(obj, sep, eq, name) {
sep = sep || '&'
eq = eq || '='
if (obj === null) {
obj = undefined
}

if (typeof obj === 'object') {
return map(objectKeys(obj), function(k) {
var ks = encodeURIComponent(stringifyPrimitive(k)) + eq
if (isArray(obj[k])) {
return map(obj[k], function(v) {
return ks + encodeURIComponent(stringifyPrimitive(v))
}).join(sep)
} else {
return ks + encodeURIComponent(stringifyPrimitive(obj[k]))
}
}).join(sep)
}

if (!name) return ''
return (
encodeURIComponent(stringifyPrimitive(name)) +
eq +
encodeURIComponent(stringifyPrimitive(obj))
)
}

var isArray =
Array.isArray ||
function(xs) {
return Object.prototype.toString.call(xs) === '[object Array]'
}

function map(xs, f) {
if (xs.map) return xs.map(f)
var res = []
for (var i = 0; i < xs.length; i++) {
res.push(f(xs[i], i))
}
return res
}

var objectKeys =
Object.keys ||
function(obj) {
var res = []
for (var key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) res.push(key)
}
return res
}

export function decode(qs, sep, eq, options) {
sep = sep || '&'
eq = eq || '='
var obj = {}
options = options || {}

var decodeComponent = options.decodeURIComponent || decodeURIComponent

if (typeof qs !== 'string' || qs.length === 0) {
return obj
}

var regexp = /\+/g
qs = qs.split(sep)

var maxKeys = 1000
if (options && typeof options.maxKeys === 'number') {
maxKeys = options.maxKeys
}

var len = qs.length
// maxKeys <= 0 means that we should not limit keys count
if (maxKeys > 0 && len > maxKeys) {
len = maxKeys
}

for (var i = 0; i < len; ++i) {
var x = qs[i].replace(regexp, '%20'),
idx = x.indexOf(eq),
kstr,
vstr,
k,
v

if (idx >= 0) {
kstr = x.substr(0, idx)
vstr = x.substr(idx + 1)
} else {
kstr = x
vstr = ''
}

k = decodeComponent(kstr)
v = decodeComponent(vstr)

if (!hasOwnProperty(obj, k)) {
obj[k] = v
} else if (isArray(obj[k])) {
obj[k].push(v)
} else {
obj[k] = [obj[k], v]
}
}

return obj
}

export const parse = decode
export const stringify = encode
12 changes: 12 additions & 0 deletions test/integration/production/pages/client-query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'

export default () => {
const router = useRouter()

useEffect(() => {
window.didHydrate = true
}, [])

return <div id="query">{JSON.stringify(router.query)}</div>
}
16 changes: 16 additions & 0 deletions test/integration/production/pages/server-query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'

const Page = () => {
const router = useRouter()

useEffect(() => {
window.didHydrate = true
}, [])

return <div id="query">{JSON.stringify(router.query)}</div>
}

Page.getInitialProps = () => ({ hello: 'world' })

export default Page
36 changes: 36 additions & 0 deletions test/integration/production/test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,42 @@ describe('Production Usage', () => {
expect(id).toBe('2')
})

it('should handle non-encoded query value server side', async () => {
const html = await renderViaHTTP(appPort, '/server-query?id=0&value=%')
const query = cheerio
.load(html)('#query')
.text()
expect(JSON.parse(query)).toEqual({
id: '0',
value: '%',
})
})

// The below tests fail on safari from an invalid URL error
if (browserName !== 'safari') {
it('should handle non-encoded query value server side with hydration', async () => {
const browser = await webdriver(appPort, '/server-query?id=0&value=%')
expect(await browser.eval('window.didHydrate')).toBe(true)

const query = await browser.elementByCss('#query').text()
expect(JSON.parse(query)).toEqual({
id: '0',
value: '%',
})
})

it('should handle non-encoded query value client side', async () => {
const browser = await webdriver(appPort, '/client-query?id=0&value=%')
expect(await browser.eval('window.didHydrate')).toBe(true)

const query = await browser.elementByCss('#query').text()
expect(JSON.parse(query)).toEqual({
id: '0',
value: '%',
})
})
}

describe('Runtime errors', () => {
it('should render a server side error on the client side', async () => {
const browser = await webdriver(appPort, '/error-in-ssr-render')
Expand Down