Skip to content

Commit 733c47e

Browse files
feat: add support for signed redirects (#5414)
1 parent e978613 commit 733c47e

File tree

5 files changed

+136
-13
lines changed

5 files changed

+136
-13
lines changed

src/utils/proxy.mjs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { NETLIFYDEVLOG, NETLIFYDEVWARN } from './command-helpers.mjs'
3333
import createStreamPromise from './create-stream-promise.mjs'
3434
import { headersForPath, parseHeaders } from './headers.mjs'
3535
import { createRewriter, onChanges } from './rules-proxy.mjs'
36+
import { signRedirect } from './sign-redirect.mjs'
3637

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

146-
const serveRedirect = async function ({ match, options, proxy, req, res }) {
147+
const serveRedirect = async function ({ match, options, proxy, req, res, siteInfo }) {
147148
if (!match) return proxy.web(req, res, options)
148149

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

159+
if (match.signingSecret) {
160+
req.headers['x-nf-sign'] = signRedirect({
161+
deployContext: 'dev',
162+
secret: match.signingSecret,
163+
siteID: siteInfo.id,
164+
siteURL: siteInfo.url,
165+
})
166+
}
167+
158168
if (isFunction(options.functionsPort, req.url)) {
159169
return proxy.web(req, res, { target: options.functionsServer })
160170
}
@@ -306,7 +316,7 @@ const reqToURL = function (req, pathname) {
306316

307317
const MILLISEC_TO_SEC = 1e3
308318

309-
const initializeProxy = async function ({ configPath, distDir, host, port, projectDir }) {
319+
const initializeProxy = async function ({ configPath, distDir, host, port, projectDir, siteInfo }) {
310320
const proxy = httpProxy.createProxyServer({
311321
selfHandleResponse: true,
312322
target: {
@@ -370,13 +380,20 @@ const initializeProxy = async function ({ configPath, distDir, host, port, proje
370380
return proxy.web(req, res, req.proxyOptions)
371381
}
372382
if (req.proxyOptions && req.proxyOptions.match) {
373-
return serveRedirect({ req, res, proxy: handlers, match: req.proxyOptions.match, options: req.proxyOptions })
383+
return serveRedirect({
384+
req,
385+
res,
386+
proxy: handlers,
387+
match: req.proxyOptions.match,
388+
options: req.proxyOptions,
389+
siteInfo,
390+
})
374391
}
375392
}
376393

377394
if (req.proxyOptions.staticFile && isRedirect({ status: proxyRes.statusCode }) && proxyRes.headers.location) {
378395
req.url = proxyRes.headers.location
379-
return serveRedirect({ req, res, proxy: handlers, match: null, options: req.proxyOptions })
396+
return serveRedirect({ req, res, proxy: handlers, match: null, options: req.proxyOptions, siteInfo })
380397
}
381398

382399
const responseData = []
@@ -472,7 +489,11 @@ const initializeProxy = async function ({ configPath, distDir, host, port, proje
472489
return handlers
473490
}
474491

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

512-
return serveRedirect({ req, res, proxy, match, options })
533+
return serveRedirect({ req, res, proxy, match, options, siteInfo })
513534
}
514535

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

575597
const rewriter = await createRewriter({
@@ -588,6 +610,7 @@ export const startProxy = async function ({
588610
addonsUrls,
589611
functionsServer,
590612
edgeFunctionsProxy,
613+
siteInfo,
591614
})
592615
const primaryServer = settings.https
593616
? https.createServer({ cert: settings.https.cert, key: settings.https.key }, onRequestWithOptions)

src/utils/redirects.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const normalizeRedirect = function ({
3636
conditions: { country, language, role, ...conditions },
3737
from,
3838
query,
39+
signed,
3940
...redirect
4041
}) {
4142
return {
@@ -48,5 +49,10 @@ const normalizeRedirect = function ({
4849
...(country && { Country: country }),
4950
...(language && { Language: language }),
5051
},
52+
...(signed && {
53+
sign: {
54+
jwt_secret: signed,
55+
},
56+
}),
5157
}
5258
}

src/utils/sign-redirect.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import jwt from 'jsonwebtoken'
2+
3+
// https://docs.netlify.com/routing/redirects/rewrites-proxies/#signed-proxy-redirects
4+
export const signRedirect = ({ deployContext, secret, siteID, siteURL }) => {
5+
const claims = {
6+
deploy_context: deployContext,
7+
netlify_id: siteID,
8+
site_url: siteURL,
9+
}
10+
const options = {
11+
expiresIn: '5 minutes',
12+
issuer: 'netlify',
13+
}
14+
15+
return jwt.sign(claims, secret, options)
16+
}

tests/integration/0.command.dev.test.cjs

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ const { join } = require('path')
55
// eslint-disable-next-line ava/use-test
66
const avaTest = require('ava')
77
const { isCI } = require('ci-info')
8+
const jwt = require('jsonwebtoken')
89
const fetch = require('node-fetch')
910

1011
const { withDevServer } = require('./utils/dev-server.cjs')
1112
const { startExternalServer } = require('./utils/external-server.cjs')
1213
const got = require('./utils/got.cjs')
14+
const { withMockApi } = require('./utils/mock-api.cjs')
1315
const { withSiteBuilder } = require('./utils/site-builder.cjs')
1416

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

178180
await withDevServer({ cwd: builder.directory }, async (server) => {
179181
const getResponse = await got(`${server.url}/api/ping`).json()
180-
t.deepEqual(getResponse, { body: {}, method: 'GET', url: '/ping' })
182+
t.deepEqual(getResponse.body, {})
183+
t.is(getResponse.method, 'GET')
184+
t.is(getResponse.url, '/ping')
181185

182186
const postResponse = await got
183187
.post(`${server.url}/api/ping`, {
@@ -188,7 +192,79 @@ test('should rewrite requests to an external server', async (t) => {
188192
followRedirect: false,
189193
})
190194
.json()
191-
t.deepEqual(postResponse, { body: { param: 'value' }, method: 'POST', url: '/ping' })
195+
t.deepEqual(postResponse.body, { param: 'value' })
196+
t.is(postResponse.method, 'POST')
197+
t.is(postResponse.url, '/ping')
198+
})
199+
200+
externalServer.close()
201+
})
202+
})
203+
204+
test('should sign external redirects with the `x-nf-sign` header when a `signed` value is set', async (t) => {
205+
await withSiteBuilder('site-redirects-file-to-external', async (builder) => {
206+
const mockSigningKey = 'iamverysecret'
207+
const externalServer = startExternalServer()
208+
const { port } = externalServer.address()
209+
const siteInfo = {
210+
account_slug: 'test-account',
211+
id: 'site_id',
212+
name: 'site-name',
213+
url: 'https://cli-test-suite.netlify.ftw',
214+
}
215+
const routes = [
216+
{ path: 'sites/site_id', response: siteInfo },
217+
{ path: 'sites/site_id/service-instances', response: [] },
218+
{
219+
path: 'accounts',
220+
response: [{ slug: siteInfo.account_slug }],
221+
},
222+
]
223+
224+
await builder
225+
.withNetlifyToml({
226+
config: {
227+
redirects: [{ from: '/sign/*', to: `http://localhost:${port}/:splat`, signed: mockSigningKey, status: 200 }],
228+
},
229+
})
230+
.buildAsync()
231+
232+
await withMockApi(routes, async ({ apiUrl }) => {
233+
await withDevServer(
234+
{
235+
cwd: builder.directory,
236+
offline: false,
237+
env: {
238+
NETLIFY_API_URL: apiUrl,
239+
NETLIFY_SITE_ID: siteInfo.id,
240+
NETLIFY_AUTH_TOKEN: 'fake-token',
241+
},
242+
},
243+
async (server) => {
244+
const getResponse = await got(`${server.url}/sign/ping`).json()
245+
const postResponse = await got
246+
.post(`${server.url}/sign/ping`, {
247+
headers: {
248+
'Content-Type': 'application/x-www-form-urlencoded',
249+
},
250+
body: 'param=value',
251+
followRedirect: false,
252+
})
253+
.json()
254+
255+
;[getResponse, postResponse].forEach((response) => {
256+
const signature = response.headers['x-nf-sign']
257+
const payload = jwt.verify(signature, mockSigningKey)
258+
259+
t.is(payload.deploy_context, 'dev')
260+
t.is(payload.netlify_id, siteInfo.id)
261+
t.is(payload.site_url, siteInfo.url)
262+
t.is(payload.iss, 'netlify')
263+
})
264+
265+
t.deepEqual(postResponse.body, { param: 'value' })
266+
},
267+
)
192268
})
193269

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

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

212-
const body = await got(`${server.url}/api/ping`).json()
213-
t.deepEqual(body, { body: {}, method: 'GET', url: '/ping' })
288+
const response2 = await got(`${server.url}/api/ping`).json()
289+
t.deepEqual(response2.body, {})
290+
t.is(response2.method, 'GET')
291+
t.is(response2.url, '/ping')
214292
})
215293

216294
externalServer.close()

tests/integration/utils/external-server.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const startExternalServer = () => {
44
const app = express()
55
app.use(express.urlencoded({ extended: true }))
66
app.all('*', function onRequest(req, res) {
7-
res.json({ url: req.url, body: req.body, method: req.method })
7+
res.json({ url: req.url, body: req.body, method: req.method, headers: req.headers })
88
})
99

1010
return app.listen()

0 commit comments

Comments
 (0)