Skip to content
This repository was archived by the owner on Dec 12, 2023. It is now read-only.

feat: Add IP pinning option #16

Merged
merged 21 commits into from
Nov 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
224c7f4
Add IP pinning flag to the session options
Voltra Nov 1, 2022
919bceb
Document IP pinning in the README
Voltra Nov 1, 2022
469b24c
Add initial draft for IP pinning logic
Voltra Nov 1, 2022
0e9ddfa
Add argon2 dependency for secure hashing of IP addresses stored in th…
Voltra Nov 1, 2022
7be0310
Replace placeholder hashing logic with argon2 implementation
Voltra Nov 1, 2022
1320b82
Change the way sessions are deleted to avoid deleting on storage-jack…
Voltra Nov 1, 2022
885fe83
Improve IP address resolution, add more documentation in README
Voltra Nov 1, 2022
4037d5a
Update illegal keys detection in PATCH & POST endpoints, fix session …
Voltra Nov 1, 2022
e402eb4
Add warning about trusting IP-forwarding headers
Voltra Nov 1, 2022
e19160f
Publish changes in lockfile
Voltra Nov 2, 2022
5d9a9b6
Move IP pinning logic to a separate file
Voltra Nov 5, 2022
067e11e
Change way of getting the IP hash when creating a new session
Voltra Nov 5, 2022
70d5936
Refactor session checks into seperate functions that may throw on error
Voltra Nov 5, 2022
91cd1c5
Move conditional inside the checks themselves
Voltra Nov 5, 2022
ab85ea4
Refactor ipPinning API to allow users to configure trusted headers
Voltra Nov 5, 2022
c13b01c
Rename IP mismatch error class
Voltra Nov 9, 2022
cc4f588
Move session IP processing in the IP pinning file
Voltra Nov 9, 2022
6c23fc6
Move conditionals outside of checks
Voltra Nov 9, 2022
b0be701
Merge upstream
Voltra Nov 10, 2022
7c01cfd
Change way session are cleaned up
Voltra Nov 10, 2022
107c699
Remove unnecessary ts-ignore comments
Voltra Nov 10, 2022
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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,11 @@ Here's what the full _default_ module configuration looks like:
// The session cookie same site policy is `lax`
cookieSameSite: 'lax',
// In-memory storage is used (these are `unjs/unstorage` options)
storageOptions: {}
storageOptions: {},
// The request-domain is strictly used for the cookie, no sub-domains allowed
domain: null
domain: null,
// Sessions aren't pinned to the user's IP address
ipPinning: false
},
api: {
// The API is enabled
Expand Down Expand Up @@ -256,10 +258,11 @@ Without further ado, here's some attack cases you can consider and take action a
- possible mitigations:
- disable reading of data on the client side by disabling the api or setting `api: { methods: [] }`
- increase the default sessionId length (although with `64` characters it already is quite long, in 2022)
- use the `ipPinning` flag (although this means that everytime the user changes IP address, they'll lose their current session)
4. stealing session id(s) of client(s)
- problem: session data can leak
- possible mitigations:
- increase cookie protection, e.g., by setting `session.cookieSameSite: 'stric'` (default: `lax`)
- increase cookie protection, e.g., by setting `session.cookieSameSite: 'strict'` (default: `lax`)
- use very short-lived sessions
- don't allow session renewal

Expand Down
220 changes: 85 additions & 135 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"@nuxt/kit": "^3.0.0-rc.12",
"argon2": "^0.30.1",
"dayjs": "^1.11.5",
"defu": "^6.1.0",
"h3": "^0.8.5",
Expand Down
124 changes: 20 additions & 104 deletions src/module.ts
Original file line number Diff line number Diff line change
@@ -1,122 +1,33 @@
import { addImportsDir, addServerHandler, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
import { CreateStorageOptions } from 'unstorage'
import { defu } from 'defu'

export type SameSiteOptions = 'lax' | 'strict' | 'none'
export type SupportedSessionApiMethods = 'patch' | 'delete' | 'get' | 'post'

declare interface SessionOptions {
/**
* Set the session duration in seconds. Once the session expires, a new one with a new id will be created. Set to `null` for infinite sessions
* @default 600
* @example 30
* @type number | null
*/
expiryInSeconds: number | null
/**
* How many characters the random session id should be long
* @default 64
* @example 128
* @type number
*/
idLength: number
/**
* What prefix to use to store session information via `unstorage`
* @default 64
* @example 128
* @type number
* @docs https://github.com/unjs/unstorage
*/
storePrefix: string
/**
* When to attach session cookie to requests
* @default 'lax'
* @example 'strict'
* @type SameSiteOptions
* @docs https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
*/
cookieSameSite: SameSiteOptions
/**
* Driver configuration for session-storage. Per default in-memory storage is used
* @default {}
* @example { driver: redisDriver({ base: 'storage:' }) }
* @type CreateStorageOptions
* @docs https://github.com/unjs/unstorage
*/
storageOptions: CreateStorageOptions,
/**
* Set the domain the session cookie will be receivable by. Setting `domain: null` results in setting the domain the cookie is initially set on. Specifying a domain will allow the domain and all its sub-domains.
* @default null
* @example '.example.com'
* @type string | null
* @docs https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent
*/
domain: string | null
}

declare interface ApiOptions {
/**
* Whether to enable the session API endpoints that allow read, update and delete operations from the client side. Use `/api/session` to access the endpoints.
* @default true
* @example false
* @type boolean
*/
isEnabled: boolean
/**
* Configure which session API methods are enabled. All api methods are enabled by default. Restricting the enabled methods can be useful if you want to allow the client to read session-data but not modify it. Passing
* an empty array will result in all API methods being registered. Disable the api via the `api.isEnabled` option.
* @default []
* @example ['get']
* @type SupportedSessionApiMethods[]
*/
methods: SupportedSessionApiMethods[]
/**
* Base path of the session api.
* @default /api/session
* @example /_session
* @type string
*/
basePath: string
}

export interface ModuleOptions {
/**
* Whether to enable the module
* @default true
* @example true
* @type boolean
*/
isEnabled: boolean,
/**
* Configure session-behvaior
* @type SessionOptions
*/
session: Partial<SessionOptions>
/**
* Configure session-api and composable-behavior
* @type ApiOptions
*/
api: Partial<ApiOptions>
}
import type {
FilledModuleOptions,
ModuleOptions,
ModulePublicRuntimeConfig,
SessionIpPinningOptions,
SupportedSessionApiMethods
} from './types'

const PACKAGE_NAME = 'nuxt-session'

const defaults: ModuleOptions = {
const defaults: FilledModuleOptions = {
isEnabled: true,
session: {
expiryInSeconds: 60 * 10,
idLength: 64,
storePrefix: 'sessions',
cookieSameSite: 'lax',
storageOptions: {} as CreateStorageOptions,
domain: null,
storageOptions: {}
ipPinning: false as boolean|SessionIpPinningOptions
},
api: {
isEnabled: true,
methods: [],
methods: [] as SupportedSessionApiMethods[],
basePath: '/api/session'
}
}
} as const

export default defineNuxtModule<ModuleOptions>({
meta: {
Expand All @@ -140,10 +51,13 @@ export default defineNuxtModule<ModuleOptions>({
logger.info('Setting up sessions...')

// 2. Set public and private runtime configuration
const options = defu(moduleOptions, defaults)
const options: FilledModuleOptions = defu(moduleOptions, defaults)
options.api.methods = moduleOptions.api.methods.length > 0 ? moduleOptions.api.methods : ['patch', 'delete', 'get', 'post']
nuxt.options.runtimeConfig.session = defu(nuxt.options.runtimeConfig.session, options)
nuxt.options.runtimeConfig.public = defu(nuxt.options.runtimeConfig.public, { session: { api: options.api } })
// @ts-ignore TODO: Fix this `nuxi prepare` bug (see https://github.com/nuxt/framework/issues/8728)
nuxt.options.runtimeConfig.session = defu(nuxt.options.runtimeConfig.session, options) as FilledModuleOptions

const publicConfig: ModulePublicRuntimeConfig = { session: { api: options.api } }
nuxt.options.runtimeConfig.public = defu(nuxt.options.runtimeConfig.public, publicConfig)

// 3. Locate runtime directory and transpile module
const { resolve } = createResolver(import.meta.url)
Expand Down Expand Up @@ -174,3 +88,5 @@ export default defineNuxtModule<ModuleOptions>({
logger.success('Session setup complete')
}
})

export * from './types'
3 changes: 1 addition & 2 deletions src/runtime/composables/useSession.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { useFetch, createError } from '#app'
import { nanoid } from 'nanoid'
import { Ref, ref } from 'vue'
import type { SupportedSessionApiMethods } from '../../module'
import type { Session } from '../server/middleware/session'
import type { Session, SupportedSessionApiMethods } from '../../types'
import { useRuntimeConfig } from '#imports'

type SessionData = Record<string, any>
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/server/api/session.patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const checkIfObjectAndContainsIllegalKeys = (shape: unknown): shape is Ob
}

// see https://stackoverflow.com/a/39283005 for this usage
return Object.prototype.hasOwnProperty.call(shape, 'id') || Object.prototype.hasOwnProperty.call(shape, 'createdAt')
return !!['id', 'createdAt', 'ip'].find(key => Object.prototype.hasOwnProperty.call(shape, key))
}

export default eventHandler(async (event) => {
Expand Down
5 changes: 3 additions & 2 deletions src/runtime/server/api/session.post.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eventHandler, readBody } from 'h3'
import { createError, eventHandler, readBody } from 'h3'
import { checkIfObjectAndContainsIllegalKeys } from './session.patch'

export default eventHandler(async (event) => {
Expand All @@ -9,9 +9,10 @@ export default eventHandler(async (event) => {

// Fully overwrite the session with body data, only keep sessions own properties (id, createdAt)
event.context.session = {
...body,
id: event.context.session.id,
createdAt: event.context.session.createdAt,
...body
ip: event.context.session.ip
}

return event.context.session
Expand Down
17 changes: 17 additions & 0 deletions src/runtime/server/middleware/session/exceptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export class IpMismatch extends Error {
constructor (message = 'User IP doesn\'t match the one in session') {
super(message)
}
}

export class IpMissingFromSession extends Error {
constructor (message = 'No IP in session even though ipPinning is enabled') {
super(message)
}
}

export class SessionExpired extends Error {
constructor (message = 'Session expired') {
super(message)
}
}
58 changes: 36 additions & 22 deletions src/runtime/server/middleware/session/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { H3Event, eventHandler, setCookie, parseCookies, deleteCookie } from 'h3'
import { deleteCookie, eventHandler, H3Event, parseCookies, setCookie } from 'h3'
import { nanoid } from 'nanoid'
import dayjs from 'dayjs'
import type { SameSiteOptions } from '../../../../module'
import { SameSiteOptions, Session, SessionOptions } from '../../../../types'
import { dropStorageSession, getStorageSession, setStorageSession } from './storage'
import { processSessionIp, getHashedIpAddress } from './ipPinning'
import { SessionExpired } from './exceptions'
import { useRuntimeConfig } from '#imports'

const SESSION_COOKIE_NAME = 'sessionId'
Expand All @@ -16,13 +18,14 @@ const safeSetCookie = (event: H3Event, name: string, value: string) => setCookie
// Do not send cookies on many cross-site requests to mitigates CSRF and cross-site attacks, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite#lax
sameSite: useRuntimeConfig().session.session.cookieSameSite as SameSiteOptions,
// Set cookie for subdomain
domain: useRuntimeConfig().session.session.domain,
domain: useRuntimeConfig().session.session.domain
})

export declare interface Session {
id: string
createdAt: Date
[key: string]: any
const checkSessionExpirationTime = (session: Session, sessionExpiryInSeconds: number) => {
const now = dayjs()
if (now.diff(dayjs(session.createdAt), 'seconds') > sessionExpiryInSeconds) {
throw new SessionExpired()
}
}

/**
Expand Down Expand Up @@ -56,15 +59,19 @@ export const deleteSession = async (event: H3Event) => {
}

const newSession = async (event: H3Event) => {
// Cleanup old session data to avoid leaks
await deleteSession(event)
const runtimeConfig = useRuntimeConfig()
const sessionOptions = runtimeConfig.session.session

// (Re-)Set cookie
const sessionId = nanoid(useRuntimeConfig().session.session.idLength)
const sessionId = nanoid(sessionOptions.idLength)
safeSetCookie(event, SESSION_COOKIE_NAME, sessionId)

// Store session data in storage
const session: Session = { id: sessionId, createdAt: new Date() }
const session: Session = {
id: sessionId,
createdAt: new Date(),
ip: sessionOptions.ipPinning ? await getHashedIpAddress(event) : undefined
}
await setStorageSession(sessionId, session)

return session
Expand All @@ -83,24 +90,31 @@ const getSession = async (event: H3Event): Promise<null | Session> => {
return null
}

// 3. Is the session not expired?
const sessionExpiryInSeconds = useRuntimeConfig().session.session.expiryInSeconds
if (sessionExpiryInSeconds !== null) {
const now = dayjs()
if (now.diff(dayjs(session.createdAt), 'seconds') > sessionExpiryInSeconds) {
return null
const runtimeConfig = useRuntimeConfig()
const sessionOptions = runtimeConfig.session.session as SessionOptions
const sessionExpiryInSeconds = sessionOptions.expiryInSeconds

try {
// 3. Is the session not expired?
if (sessionExpiryInSeconds !== null) {
checkSessionExpirationTime(session, sessionExpiryInSeconds)
}

// 4. Check for IP pinning logic
if (sessionOptions.ipPinning) {
await processSessionIp(event, session)
}
} catch {
await deleteSession(event) // Cleanup old session data to avoid leaks

return null
}

return session
}

function isSession (shape: unknown): shape is Session {
if (typeof shape === 'object' && !!shape && 'id' in shape && 'createdAt' in shape) {
return true
}

return false
return typeof shape === 'object' && !!shape && 'id' in shape && 'createdAt' in shape
}

const ensureSession = async (event: H3Event) => {
Expand Down
Loading