From 76a5eedfec9003c4b757a816517f6f68d8291396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eduardo=20Bou=C3=A7as?= Date: Fri, 20 Jan 2023 13:51:05 +0000 Subject: [PATCH] feat: add support for signed redirects --- src/utils/proxy.mjs | 35 ++++++-- src/utils/redirects.mjs | 6 ++ src/utils/sign-redirect.mjs | 16 ++++ tests/integration/0.command.dev.test.cjs | 90 +++++++++++++++++++-- tests/integration/utils/external-server.cjs | 2 +- 5 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 src/utils/sign-redirect.mjs diff --git a/src/utils/proxy.mjs b/src/utils/proxy.mjs index baf6208e988..42a778c4bfd 100644 --- a/src/utils/proxy.mjs +++ b/src/utils/proxy.mjs @@ -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') @@ -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 || {} @@ -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 }) } @@ -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: { @@ -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 = [] @@ -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) @@ -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 @@ -570,6 +591,7 @@ export const startProxy = async function ({ distDir: settings.dist, projectDir, configPath, + siteInfo, }) const rewriter = await createRewriter({ @@ -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) diff --git a/src/utils/redirects.mjs b/src/utils/redirects.mjs index 6a0ac422f61..789c746ef40 100644 --- a/src/utils/redirects.mjs +++ b/src/utils/redirects.mjs @@ -36,6 +36,7 @@ const normalizeRedirect = function ({ conditions: { country, language, role, ...conditions }, from, query, + signed, ...redirect }) { return { @@ -48,5 +49,10 @@ const normalizeRedirect = function ({ ...(country && { Country: country }), ...(language && { Language: language }), }, + ...(signed && { + sign: { + jwt_secret: signed, + }, + }), } } diff --git a/src/utils/sign-redirect.mjs b/src/utils/sign-redirect.mjs new file mode 100644 index 00000000000..812ec31d000 --- /dev/null +++ b/src/utils/sign-redirect.mjs @@ -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) +} diff --git a/tests/integration/0.command.dev.test.cjs b/tests/integration/0.command.dev.test.cjs index a749fe0c071..81254b8a3fd 100644 --- a/tests/integration/0.command.dev.test.cjs +++ b/tests/integration/0.command.dev.test.cjs @@ -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 @@ -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`, { @@ -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() @@ -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() diff --git a/tests/integration/utils/external-server.cjs b/tests/integration/utils/external-server.cjs index b60ac6641d7..9a0c00ee3bc 100644 --- a/tests/integration/utils/external-server.cjs +++ b/tests/integration/utils/external-server.cjs @@ -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()