next.js/test/development/basic/allowed-dev-origins.test.ts
allowed-dev-origins.test.ts541 lines16.4 KB
import http from 'http'
import { join } from 'path'
import webdriver from 'next-webdriver'
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'e2e-utils'
import { fetchViaHTTP, findPort, retry } from 'next-test-utils'

async function createHostServer() {
  const server = http.createServer((req, res) => {
    res.end(`
      <html>
        <head>
          <title>testing cross-site</title> 
        </head>
        <body></body>
      </html>
    `)
  })

  const port = await findPort()
  await new Promise<void>((res) => {
    server.listen(port, () => res())
  })

  return {
    server,
    port,
  }
}

function withBasePath(basePath: string, path: string) {
  return `${basePath}${path}`
}

function getImageOptimizerPath(basePath: string) {
  return withBasePath(
    basePath,
    `/_next/image?url=${encodeURIComponent(withBasePath(basePath, '/image.png'))}&w=256&q=75`
  )
}

function requestInternalDevScript(
  appPort: string | number,
  basePath: string,
  options: { referer?: string } = {}
) {
  return fetchViaHTTP(
    appPort,
    withBasePath(basePath, '/_next/static/chunks/pages/_app.js'),
    undefined,
    {
      headers: {
        ...(options.referer ? { referer: options.referer } : {}),
        'sec-fetch-mode': 'no-cors',
        'sec-fetch-site': 'cross-site',
      },
    }
  )
}

function requestInternalDevMiddleware(
  appPort: string | number,
  basePath: string,
  origin: string
) {
  return fetchViaHTTP(
    appPort,
    withBasePath(
      basePath,
      '/__nextjs_error_feedback?errorCode=0&wasHelpful=true'
    ),
    undefined,
    {
      headers: {
        origin,
      },
    }
  )
}

async function expectBlockedDevResourceMessage(
  next: NextInstance,
  options: {
    resourcePath: string
    source?: string
    suggestionHost?: string
    unknownSource?: true
    opaqueOrigin?: true
  }
) {
  // I/O may not be flushed immediately, so retry until we see the message in the output.
  await retry(() => {
    expect(next.cliOutput).toContain(options.resourcePath)
  })
  const output = next.cliOutput
  expect(output).toContain(
    'Cross-origin access to Next.js dev resources is blocked by default for safety.'
  )

  if (options.opaqueOrigin) {
    expect(output).toContain('from a privacy-sensitive or opaque origin')
    expect(output).not.toContain("allowedDevOrigins: ['null']")
    return
  }

  if (options.unknownSource) {
    expect(output).toContain('from an unknown source')
    expect(output).toContain(
      'This request did not include an allowlistable source host.'
    )
    return
  }

  expect(output).toContain(`from "${options.source}"`)
  expect(output).toContain(
    'To allow this host in development, add it to "allowedDevOrigins" in next.config.js and restart the dev server:'
  )
  expect(output).toContain(
    `allowedDevOrigins: ['${options.suggestionHost ?? options.source}']`
  )
}

describe.each(['', '/docs'])(
  'allowed-dev-origins, basePath: %p',
  (basePath: string) => {
    let next: NextInstance

    describe('default blocking', () => {
      beforeAll(async () => {
        next = await createNext({
          files: {
            pages: new FileRef(join(__dirname, 'misc/pages')),
            public: new FileRef(join(__dirname, 'misc/public')),
          },
          nextConfig: {
            basePath,
          },
        })

        // render 404 page to generate
        // "/_next/static/chunks/pages/_app.js"
        // we need this because not found static assets
        // served as plain text 404 instead of HTML.
        await next.render(withBasePath(basePath, '/404'))

        await retry(async () => {
          // make sure host server is running
          const res = await fetchViaHTTP(
            next.appPort,
            withBasePath(basePath, '/_next/static/chunks/pages/_app.js')
          )
          expect(res.status).toBe(200)
        })
      })
      afterAll(() => next.destroy())

      it('should block WebSocket from cross-site', async () => {
        const { server, port } = await createHostServer()
        try {
          const websocketSnippet = `(() => {
              const statusEl = document.createElement('p')
              statusEl.id = 'status'
              document.querySelector('body').appendChild(statusEl)
  
              const ws = new WebSocket("${next.url}${withBasePath(basePath, '/_next/hmr')}")
              
              ws.addEventListener('error', (err) => {
                statusEl.innerText = 'error'
              })
              ws.addEventListener('open', () => {
                statusEl.innerText = 'connected'
              })
            })()`

          // ensure direct port with mismatching port is blocked
          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
          await browser.eval(websocketSnippet)
          await retry(async () => {
            expect(await browser.elementByCss('#status').text()).toBe('error')
          })

          // ensure different host is blocked
          await browser.get(`https://example.vercel.sh/`)
          await browser.eval(websocketSnippet)
          await retry(async () => {
            expect(await browser.elementByCss('#status').text()).toBe('error')
          })

          await expectBlockedDevResourceMessage(next, {
            resourcePath: withBasePath(basePath, '/_next/hmr'),
            source: 'example.vercel.sh',
          })
        } finally {
          server.close()
        }
      })

      it('should block loading scripts from cross-site', async () => {
        const port = await findPort()

        const mismatchedPortRes = await requestInternalDevScript(
          next.appPort,
          basePath,
          {
            referer: `http://127.0.0.1:${port}/about`,
          }
        )
        expect(mismatchedPortRes.status).toBe(403)

        const differentHostRes = await requestInternalDevScript(
          next.appPort,
          basePath,
          {
            referer: 'https://example.vercel.sh/about',
          }
        )
        expect(differentHostRes.status).toBe(403)

        await expectBlockedDevResourceMessage(next, {
          resourcePath: withBasePath(
            basePath,
            '/_next/static/chunks/pages/_app.js'
          ),
          source: 'example.vercel.sh',
        })
      })

      it('should block loading internal middleware from cross-site', async () => {
        const port = await findPort()

        const mismatchedPortRes = await requestInternalDevMiddleware(
          next.appPort,
          basePath,
          `http://127.0.0.1:${port}`
        )
        expect(mismatchedPortRes.status).toBe(403)

        const differentHostRes = await requestInternalDevMiddleware(
          next.appPort,
          basePath,
          'https://example.vercel.sh'
        )
        expect(differentHostRes.status).toBe(403)

        await expectBlockedDevResourceMessage(next, {
          resourcePath: withBasePath(basePath, '/__nextjs_error_feedback'),
          source: 'example.vercel.sh',
        })
      })

      it('should allow requests from multi-level localhost subdomains', async () => {
        const res = await requestInternalDevMiddleware(
          next.appPort,
          basePath,
          'https://sub.app.localhost'
        )
        expect(res.status).not.toBe(403)
      })
      it('should allow same-site requests without an origin header', async () => {
        const res = await fetchViaHTTP(
          next.appPort,
          withBasePath(basePath, '/_next/static/chunks/pages/_app.js')
        )
        expect(res.status).toBe(200)
      })
    })

    describe('configured but not allowlisted origins', () => {
      beforeAll(async () => {
        next = await createNext({
          files: {
            pages: new FileRef(join(__dirname, 'misc/pages')),
            public: new FileRef(join(__dirname, 'misc/public')),
          },
          nextConfig: {
            basePath,
            allowedDevOrigins: ['127.0.0.1'],
          },
        })

        await next.render(withBasePath(basePath, '/404'))

        await retry(async () => {
          const res = await fetchViaHTTP(
            next.appPort,
            withBasePath(basePath, '/_next/static/chunks/pages/_app.js')
          )
          expect(res.status).toBe(200)
        })
      })
      afterAll(() => next.destroy())

      it('should block websocket requests from configured but non-allowlisted hosts', async () => {
        const { server, port } = await createHostServer()
        try {
          const websocketSnippet = `(() => {
              const statusEl = document.createElement('p')
              statusEl.id = 'status'
              document.querySelector('body').appendChild(statusEl)

              const ws = new WebSocket("${next.url}${withBasePath(basePath, '/_next/hmr')}")

              ws.addEventListener('error', () => {
                statusEl.innerText = 'error'
              })
              ws.addEventListener('open', () => {
                statusEl.innerText = 'connected'
              })
            })()`

          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
          await browser.get(`https://example.vercel.sh/`)
          await browser.eval(websocketSnippet)
          await retry(async () => {
            expect(await browser.elementByCss('#status').text()).toBe('error')
          })
        } finally {
          server.close()
        }
      })

      it('should block no-cors requests from configured but non-allowlisted hosts', async () => {
        const res = await requestInternalDevScript(next.appPort, basePath, {
          referer: 'https://example.vercel.sh/about',
        })
        expect(res.status).toBe(403)
      })
    })

    describe('configured allowed origins', () => {
      beforeAll(async () => {
        next = await createNext({
          files: {
            pages: new FileRef(join(__dirname, 'misc/pages')),
            public: new FileRef(join(__dirname, 'misc/public')),
          },
          nextConfig: {
            basePath,
            allowedDevOrigins: ['127.0.0.1', 'example.vercel.sh'],
          },
        })

        // render 404 page to generate
        // "/_next/static/chunks/pages/_app.js"
        // since we haven't built any paths by this point
        // causing this chunk to not be written to disk yet
        await next.render(withBasePath(basePath, '/404'))

        await retry(async () => {
          // make sure host server is running
          const res = await fetchViaHTTP(
            next.appPort,
            withBasePath(basePath, '/_next/static/chunks/pages/_app.js')
          )
          expect(res.status).toBe(200)
        })
      })
      afterAll(() => next.destroy())

      it('should allow dev WebSocket from configured cross-site', async () => {
        const { server, port } = await createHostServer()
        try {
          const websocketSnippet = `(() => {
              const statusEl = document.createElement('p')
              statusEl.id = 'status'
              document.querySelector('body').appendChild(statusEl)
  
              const ws = new WebSocket("${next.url}${withBasePath(basePath, '/_next/hmr')}")
              
              ws.addEventListener('error', (err) => {
                statusEl.innerText = 'error'
              })
              ws.addEventListener('open', () => {
                statusEl.innerText = 'connected'
              })
            })()`

          // ensure direct port with mismatching port is allowed when configured
          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')
          await browser.eval(websocketSnippet)
          await retry(async () => {
            expect(await browser.elementByCss('#status').text()).toBe(
              'connected'
            )
          })

          // ensure different host is allowed when configured
          await browser.get(`https://example.vercel.sh/`)
          await browser.eval(websocketSnippet)
          await retry(async () => {
            expect(await browser.elementByCss('#status').text()).toBe(
              'connected'
            )
          })
        } finally {
          server.close()
        }
      })

      it('should allow loading scripts from configured cross-site', async () => {
        const port = await findPort()

        const mismatchedPortRes = await requestInternalDevScript(
          next.appPort,
          basePath,
          {
            referer: `http://127.0.0.1:${port}/about`,
          }
        )
        expect(mismatchedPortRes.status).toBe(200)

        const differentHostRes = await requestInternalDevScript(
          next.appPort,
          basePath,
          {
            referer: 'https://example.vercel.sh/about',
          }
        )
        expect(differentHostRes.status).toBe(200)
      })

      it('should block no-cors requests without a referer even when origins are configured', async () => {
        const res = await requestInternalDevScript(next.appPort, basePath)
        expect(res.status).toBe(403)

        await expectBlockedDevResourceMessage(next, {
          resourcePath: withBasePath(
            basePath,
            '/_next/static/chunks/pages/_app.js'
          ),
          unknownSource: true,
        })
      })

      it('should allow loading internal middleware from configured cross-site', async () => {
        const port = await findPort()

        const mismatchedPortRes = await requestInternalDevMiddleware(
          next.appPort,
          basePath,
          `http://127.0.0.1:${port}`
        )
        expect(mismatchedPortRes.status).toBe(204)

        const differentHostRes = await requestInternalDevMiddleware(
          next.appPort,
          basePath,
          'https://example.vercel.sh'
        )
        expect(differentHostRes.status).toBe(204)
      })

      it('should load images regardless of allowed origins', async () => {
        const { server, port } = await createHostServer()
        try {
          const browser = await webdriver(`http://127.0.0.1:${port}`, '/about')

          const imageSnippet = `(() => {
            const statusEl = document.createElement('p')
            statusEl.id = 'status'
            document.querySelector('body').appendChild(statusEl)

            const image = document.createElement('img')
            image.src = "${next.url}${getImageOptimizerPath(basePath)}"
            document.querySelector('body').appendChild(image)
            image.onload = () => {
              statusEl.innerText = 'OK'
            }
            image.onerror = () => {
              statusEl.innerText = 'Unauthorized'
            }
          })()`

          await browser.eval(imageSnippet)

          await retry(async () => {
            expect(await browser.elementByCss('#status').text()).toBe('OK')
          })
        } finally {
          server.close()
        }
      })

      it('blocks cross-site requests from privacy-sensitive origins', async () => {
        const server = http.createServer((req, res) => {
          res.appendHeader('Content-Security-Policy', 'sandbox allow-scripts')
          res.end(`
            <html>
              <head>
                <title>testing cross-site privacy-sensitive</title> 
              </head>
              <body>
                <script>
                  (() => {
                    const statusEl = document.createElement('p')
                    statusEl.id = 'status'
                    document.querySelector('body').appendChild(statusEl)
        
                    const ws = new WebSocket("${next.url}${withBasePath(basePath, '/_next/hmr')}")
                    
                    ws.addEventListener('error', (err) => {
                      statusEl.innerText = 'error'
                    })
                    ws.addEventListener('open', () => {
                      statusEl.innerText = 'connected'
                    })
                  })()
                </script>
              </body>
            </html>
          `)
        })

        const port = await findPort()
        await new Promise<void>((res) => {
          server.listen(port, () => res())
        })

        try {
          const browser = await webdriver(`http://127.0.0.1:${port}`, '/')

          await retry(async () => {
            expect(await browser.elementByCss('#status').text()).toBe('error')
          })

          await expectBlockedDevResourceMessage(next, {
            resourcePath: withBasePath(basePath, '/_next/hmr'),
            opaqueOrigin: true,
          })
        } finally {
          await new Promise<void>((res) => {
            server.close(() => {
              res()
            })
          })
        }
      })
    })
  }
)
Quest for Codev2.0.0
/
SIGN IN