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

feat(aws): Set explicit inferring of AWS #2

Merged
merged 3 commits into from
Jan 16, 2023
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,18 @@ const fastify = require('fastify')({

fastify.register(require('fastify-ip'), {
order: ['x-my-ip-header'],
strict: false
strict: false,
isAWS: false,
})
```

### Options

- `order` - `string[] | string` - **optional**: Array of custom headers or single custom header to be appended to the prior list of well-known headers. The headers passed will be prepend to the default headers list. It can also be used to alter the order of the list as deduplication of header names is made while loading the plugin.

- `strict` - `boolean` - **optional**: Indicates whether to override the default list of well-known headers and replace it with the header(s) passed through the `order` option. If set to `true` without `order` property being provided, will not take any effect on the plugin. Default `false`.
- `strict` - `boolean` - **optional**: Indicates whether to override the default list of well-known headers and replace it with the header(s) passed through the `order` option. If set to `true` without `order` or `isAWS` properties provided, it will lead to throwing an exception. Default `false`.

- `isAWS` - `boolean` - **optional**: Indicates wether the plugin should explicitly try to infer the IP from the decorations made at the native Node.js HTTP Request object included in the Fastify Request. If set to `true` the plugin will treat this approach as a first option. Otherwise it will use it just as a fallback. Default `false`.


### API
Expand Down Expand Up @@ -99,6 +102,7 @@ app.post('/', (request: FastifyRequest, reply: FastifyReply) => {
export interface FastifyIPOptions {
order?: string[] | string;
strict?: boolean;
isAWS?: boolean;
}

declare module 'fastify' {
Expand Down
1 change: 1 addition & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FastifyPluginCallback } from 'fastify';
export interface FastifyIPOptions {
order?: string[] | string;
strict?: boolean;
isAWS?: boolean
}

declare module 'fastify' {
Expand Down
54 changes: 40 additions & 14 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ const plugin = fp(fastifyIp, {
name: 'fastify-ip'
})

function fastifyIp (instance, options, done) {
const { order: inputOrder, strict } = options
function fastifyIp (
instance,
{ order: inputOrder, strict, isAWS } = {
order: null,
strict: false,
isAWS: false
},
done
) {
/*! Based on request-ip#https://github.com/pbojinov/request-ip/blob/9501cdf6e73059cc70fc6890adb086348d7cca46/src/index.js.
MIT License. 2022 Petar Bojinov - [email protected] */
// Default headers
Expand All @@ -27,19 +34,26 @@ function fastifyIp (instance, options, done) {
'forwarded',
'x-appengine-user-ip' // GCP App Engine
]
let error

if (inputOrder != null) {
if (strict && inputOrder == null && !isAWS) {
error = new Error('If strict provided, order or isAWS are mandatory')
} else if (inputOrder != null) {
if (Array.isArray(inputOrder) && inputOrder.length > 0) {
headersOrder = strict
? [].concat(inputOrder)
: [...new Set([].concat(inputOrder, headersOrder))]
} else if (typeof inputOrder === 'string' && inputOrder.length > 0) {
headersOrder = strict ? [inputOrder] : (headersOrder.unshift(inputOrder), headersOrder)
headersOrder = strict
? [inputOrder]
: (headersOrder.unshift(inputOrder), headersOrder)
} else {
done(new Error('invalid order option'))
error = new Error('invalid order option')
}
}

if (error != null) return done(error)

// Utility methods
instance.decorateRequest('isIP', isIP)
instance.decorateRequest('isIPv4', isIPv4)
Expand All @@ -50,29 +64,41 @@ function fastifyIp (instance, options, done) {
// Core method
instance.decorateRequest('ip', {
getter: function () {
if (this._fastifyip !== '') return this._fastifyip
let ip = this._fastifyip
if (ip !== '') return ip

// AWS Api Gateway + Lambda
if (this.raw.requestContext != null) {
const pseudoIP = this.raw.requestContext.identity?.sourceIp
if (pseudoIP != null && this.isIP(pseudoIP)) {
this._fastifyip = pseudoIP
}
} else {
// If is AWS context or the rules are not strict
// infer first from AWS monkey-patching
if (isAWS || !strict) {
this._fastifyip = inferFromAWSContext.apply(this)
ip = this._fastifyip
}

// If is an AWS context, the rules are soft
// or is not AWS context and the ip has not been
// inferred yet, try using the request headers
if (((isAWS && !strict) || !isAWS) && ip === '') {
for (const headerKey of headersOrder) {
const value = this.headers[headerKey]
if (value != null && this.isIP(value)) {
this._fastifyip = value
ip = this._fastifyip
break
}
}
}

return this._fastifyip
return ip
}
})

done()

// AWS Api Gateway + Lambda
function inferFromAWSContext () {
const pseudoIP = this.raw.requestContext?.identity?.sourceIp
return pseudoIP != null && this.isIP(pseudoIP) ? pseudoIP : ''
}
}

module.exports = plugin
Expand Down
138 changes: 137 additions & 1 deletion test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ tap.test('Plugin#Decoration', scope => {
})

tap.test('Plugin#Request IP', scope => {
scope.plan(6)
scope.plan(8)

scope.test('Should infer the header based on default priority', async t => {
const app = fastify()
Expand All @@ -139,6 +139,142 @@ tap.test('Plugin#Request IP', scope => {
})
})

scope.test('Fallback behavior on AWS Context', async t => {
const app = fastify()
const expectedIP = faker.internet.ip()
const secondaryIP = faker.internet.ip()
const childscope1 = (instance, _, done) => {
instance.register(plugin, { isAWS: true, order: ['x-custom-remote-ip'] })

instance.get('/first', (req, reply) => {
t.equal(req.ip, expectedIP)
t.equal(req._fastifyip, expectedIP)

reply.send('')
})

instance.get('/second', (req, reply) => {
t.equal(req.ip, secondaryIP)
t.equal(req._fastifyip, secondaryIP)

reply.send('')
})

done()
}
const childscope2 = (instance, _, done) => {
instance.register(plugin, { isAWS: true, strict: true })

instance.get('/', (req, reply) => {
t.equal(req.ip, '')
t.equal(req._fastifyip, '')

reply.send('')
})

done()
}
const childscope3 = (instance, _, done) => {
instance.register(plugin, { isAWS: true })

instance.get('/', (req, reply) => {
t.equal(req.ip, expectedIP)
t.equal(req._fastifyip, expectedIP)

reply.send('')
})

instance.get('/none', (req, reply) => {
t.equal(req.ip, '')
t.equal(req._fastifyip, '')

reply.send('')
})

done()
}

t.plan(10)

app.register(childscope1, { prefix: '/fallback' })
app.register(childscope2, { prefix: '/no-fallback' })
app.register(childscope3, { prefix: '/soft' })

await app.inject({
path: '/fallback/first',
headers: {
'x-custom-remote-ip': expectedIP,
'x-forwarded-for': secondaryIP
}
})

await app.inject({
path: '/fallback/second',
headers: {
'x-forwarded-for': secondaryIP
}
})

await app.inject({
path: '/no-fallback',
headers: {
'x-custom-remote-ip': expectedIP,
'x-forwarded-for': secondaryIP
}
})

await app.inject({
path: '/soft',
headers: {
'x-appengine-user-ip': secondaryIP,
'x-real-ip': expectedIP
}
})

await app.inject({
path: '/soft/none'
})
})

scope.test('Should infer the header based on if is AWS context', async t => {
const app = fastify()
const expectedIP = faker.internet.ip()
const fallbackIP = faker.internet.ipv6()

app.register(plugin, { isAWS: true })

app.get(
'/',
{
preHandler: function (req, reply, done) {
req.raw.requestContext = {
identity: {
sourceIp: expectedIP
}
}
done()
}
},
(req, reply) => {
t.equal(req.ip, expectedIP)
t.equal(req._fastifyip, expectedIP)

reply.send('')
}
)

t.plan(2)

await app.inject({
path: '/',
headers: {
'cf-connecting-ip': fallbackIP,
'x-client-ip': faker.internet.ipv6(),
'x-custom-remote-ip': expectedIP
}
})
})

scope.test(
'Should infer the header based on custom priority <Array>',
async t => {
Expand Down