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
35 changes: 29 additions & 6 deletions src/utils/proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { NETLIFYDEVLOG, NETLIFYDEVWARN } from './command-helpers.mjs'
import createStreamPromise from './create-stream-promise.mjs'
import { headersForPath, parseHeaders } from './headers.mjs'
import { createRewriter, onChanges } from './rules-proxy.mjs'
import { signRedirect } from './sign-redirect.mjs'

const decompress = util.promisify(zlib.gunzip)
const shouldGenerateETag = Symbol('Internal: response should generate ETag')
Expand Down Expand Up @@ -143,7 +144,7 @@ const alternativePathsFor = function (url) {
return paths
}

const serveRedirect = async function ({ match, options, proxy, req, res }) {
const serveRedirect = async function ({ match, options, proxy, req, res, siteInfo }) {
if (!match) return proxy.web(req, res, options)

options = options || req.proxyOptions || {}
Expand All @@ -155,6 +156,15 @@ const serveRedirect = async function ({ match, options, proxy, req, res }) {
})
}

if (match.signingSecret) {
req.headers['x-nf-sign'] = signRedirect({
deployContext: 'dev',
secret: match.signingSecret,
siteID: siteInfo.id,
siteURL: siteInfo.url,
})
}

if (isFunction(options.functionsPort, req.url)) {
return proxy.web(req, res, { target: options.functionsServer })
}
Expand Down Expand Up @@ -306,7 +316,7 @@ const reqToURL = function (req, pathname) {

const MILLISEC_TO_SEC = 1e3

const initializeProxy = async function ({ configPath, distDir, host, port, projectDir }) {
const initializeProxy = async function ({ configPath, distDir, host, port, projectDir, siteInfo }) {
const proxy = httpProxy.createProxyServer({
selfHandleResponse: true,
target: {
Expand Down Expand Up @@ -370,13 +380,20 @@ const initializeProxy = async function ({ configPath, distDir, host, port, proje
return proxy.web(req, res, req.proxyOptions)
}
if (req.proxyOptions && req.proxyOptions.match) {
return serveRedirect({ req, res, proxy: handlers, match: req.proxyOptions.match, options: req.proxyOptions })
return serveRedirect({
req,
res,
proxy: handlers,
match: req.proxyOptions.match,
options: req.proxyOptions,
siteInfo,
})
}
}

if (req.proxyOptions.staticFile && isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) {
req.url = proxyRes.headers.location
return serveRedirect({ req, res, proxy: handlers, match: null, options: req.proxyOptions })
return serveRedirect({ req, res, proxy: handlers, match: null, options: req.proxyOptions, siteInfo })
}

const responseData = []
Expand Down Expand Up @@ -472,7 +489,11 @@ const initializeProxy = async function ({ configPath, distDir, host, port, proje
return handlers
}

const onRequest = async ({ addonsUrls, edgeFunctionsProxy, functionsServer, proxy, rewriter, settings }, req, res) => {
const onRequest = async (
{ addonsUrls, edgeFunctionsProxy, functionsServer, proxy, rewriter, settings, siteInfo },
req,
res,
) => {
req.originalBody = ['GET', 'OPTIONS', 'HEAD'].includes(req.method)
? null
: await createStreamPromise(req, BYTES_LIMIT)
Expand Down Expand Up @@ -509,7 +530,7 @@ const onRequest = async ({ addonsUrls, edgeFunctionsProxy, functionsServer, prox
// We don't want to generate an ETag for 3xx redirects.
req[shouldGenerateETag] = ({ statusCode }) => statusCode < 300 || statusCode >= 400

return serveRedirect({ req, res, proxy, match, options })
return serveRedirect({ req, res, proxy, match, options, siteInfo })
}

// The request will be served by the framework server, which means we want to
Expand Down Expand Up @@ -570,6 +591,7 @@ export const startProxy = async function ({
distDir: settings.dist,
projectDir,
configPath,
siteInfo,
})

const rewriter = await createRewriter({
Expand All @@ -588,6 +610,7 @@ export const startProxy = async function ({
addonsUrls,
functionsServer,
edgeFunctionsProxy,
siteInfo,
})
const primaryServer = settings.https
? https.createServer({ cert: settings.https.cert, key: settings.https.key }, onRequestWithOptions)
Expand Down
6 changes: 6 additions & 0 deletions src/utils/redirects.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const normalizeRedirect = function ({
conditions: { country, language, role, ...conditions },
from,
query,
signed,
...redirect
}) {
return {
Expand All @@ -48,5 +49,10 @@ const normalizeRedirect = function ({
...(country && { Country: country }),
...(language && { Language: language }),
},
...(signed && {
sign: {
jwt_secret: signed,
},
}),
}
}
16 changes: 16 additions & 0 deletions src/utils/sign-redirect.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import jwt from 'jsonwebtoken'

// https://docs.netlify.com/routing/redirects/rewrites-proxies/#signed-proxy-redirects
export const signRedirect = ({ deployContext, secret, siteID, siteURL }) => {
const claims = {
deploy_context: deployContext,
netlify_id: siteID,
site_url: siteURL,
}
const options = {
expiresIn: '5 minutes',
issuer: 'netlify',
}

return jwt.sign(claims, secret, options)
}
90 changes: 84 additions & 6 deletions tests/integration/0.command.dev.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ const { join } = require('path')
// eslint-disable-next-line ava/use-test
const avaTest = require('ava')
const { isCI } = require('ci-info')
const jwt = require('jsonwebtoken')
const fetch = require('node-fetch')

const { withDevServer } = require('./utils/dev-server.cjs')
const { startExternalServer } = require('./utils/external-server.cjs')
const got = require('./utils/got.cjs')
const { withMockApi } = require('./utils/mock-api.cjs')
const { withSiteBuilder } = require('./utils/site-builder.cjs')

const test = isCI ? avaTest.serial.bind(avaTest) : avaTest
Expand Down Expand Up @@ -177,7 +179,9 @@ test('should rewrite requests to an external server', async (t) => {

await withDevServer({ cwd: builder.directory }, async (server) => {
const getResponse = await got(`${server.url}/api/ping`).json()
t.deepEqual(getResponse, { body: {}, method: 'GET', url: '/ping' })
t.deepEqual(getResponse.body, {})
t.is(getResponse.method, 'GET')
t.is(getResponse.url, '/ping')

const postResponse = await got
.post(`${server.url}/api/ping`, {
Expand All @@ -188,7 +192,79 @@ test('should rewrite requests to an external server', async (t) => {
followRedirect: false,
})
.json()
t.deepEqual(postResponse, { body: { param: 'value' }, method: 'POST', url: '/ping' })
t.deepEqual(postResponse.body, { param: 'value' })
t.is(postResponse.method, 'POST')
t.is(postResponse.url, '/ping')
})

externalServer.close()
})
})

test('should sign external redirects with the `x-nf-sign` header when a `signed` value is set', async (t) => {
await withSiteBuilder('site-redirects-file-to-external', async (builder) => {
const mockSigningKey = 'iamverysecret'
const externalServer = startExternalServer()
const { port } = externalServer.address()
const siteInfo = {
account_slug: 'test-account',
id: 'site_id',
name: 'site-name',
url: 'https://cli-test-suite.netlify.ftw',
}
const routes = [
{ path: 'sites/site_id', response: siteInfo },
{ path: 'sites/site_id/service-instances', response: [] },
{
path: 'accounts',
response: [{ slug: siteInfo.account_slug }],
},
]

await builder
.withNetlifyToml({
config: {
redirects: [{ from: '/sign/*', to: `http://localhost:${port}/:splat`, signed: mockSigningKey, status: 200 }],
},
})
.buildAsync()

await withMockApi(routes, async ({ apiUrl }) => {
await withDevServer(
{
cwd: builder.directory,
offline: false,
env: {
NETLIFY_API_URL: apiUrl,
NETLIFY_SITE_ID: siteInfo.id,
NETLIFY_AUTH_TOKEN: 'fake-token',
},
},
async (server) => {
const getResponse = await got(`${server.url}/sign/ping`).json()
const postResponse = await got
.post(`${server.url}/sign/ping`, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'param=value',
followRedirect: false,
})
.json()

;[getResponse, postResponse].forEach((response) => {
const signature = response.headers['x-nf-sign']
const payload = jwt.verify(signature, mockSigningKey)

t.is(payload.deploy_context, 'dev')
t.is(payload.netlify_id, siteInfo.id)
t.is(payload.site_url, siteInfo.url)
t.is(payload.iss, 'netlify')
})

t.deepEqual(postResponse.body, { param: 'value' })
},
)
})

externalServer.close()
Expand All @@ -206,11 +282,13 @@ test('should follow 301 redirect to an external server', async (t) => {
await builder.buildAsync()

await withDevServer({ cwd: builder.directory }, async (server) => {
const response = await got(`${server.url}/api/ping`, { followRedirect: false })
t.is(response.headers.location, `http://localhost:${port}/ping`)
const response1 = await got(`${server.url}/api/ping`, { followRedirect: false })
t.is(response1.headers.location, `http://localhost:${port}/ping`)

const body = await got(`${server.url}/api/ping`).json()
t.deepEqual(body, { body: {}, method: 'GET', url: '/ping' })
const response2 = await got(`${server.url}/api/ping`).json()
t.deepEqual(response2.body, {})
t.is(response2.method, 'GET')
t.is(response2.url, '/ping')
})

externalServer.close()
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/utils/external-server.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const startExternalServer = () => {
const app = express()
app.use(express.urlencoded({ extended: true }))
app.all('*', function onRequest(req, res) {
res.json({ url: req.url, body: req.body, method: req.method })
res.json({ url: req.url, body: req.body, method: req.method, headers: req.headers })
})

return app.listen()
Expand Down