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

Commit c050bae

Browse files
authored
feat: Add IP pinning option (#16)
1 parent 2d2a190 commit c050bae

File tree

11 files changed

+408
-269
lines changed

11 files changed

+408
-269
lines changed

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,11 @@ Here's what the full _default_ module configuration looks like:
218218
// The session cookie same site policy is `lax`
219219
cookieSameSite: 'lax',
220220
// In-memory storage is used (these are `unjs/unstorage` options)
221-
storageOptions: {}
221+
storageOptions: {},
222222
// The request-domain is strictly used for the cookie, no sub-domains allowed
223-
domain: null
223+
domain: null,
224+
// Sessions aren't pinned to the user's IP address
225+
ipPinning: false
224226
},
225227
api: {
226228
// The API is enabled
@@ -256,10 +258,11 @@ Without further ado, here's some attack cases you can consider and take action a
256258
- possible mitigations:
257259
- disable reading of data on the client side by disabling the api or setting `api: { methods: [] }`
258260
- increase the default sessionId length (although with `64` characters it already is quite long, in 2022)
261+
- use the `ipPinning` flag (although this means that everytime the user changes IP address, they'll lose their current session)
259262
4. stealing session id(s) of client(s)
260263
- problem: session data can leak
261264
- possible mitigations:
262-
- increase cookie protection, e.g., by setting `session.cookieSameSite: 'stric'` (default: `lax`)
265+
- increase cookie protection, e.g., by setting `session.cookieSameSite: 'strict'` (default: `lax`)
263266
- use very short-lived sessions
264267
- don't allow session renewal
265268

package-lock.json

Lines changed: 85 additions & 135 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
},
3131
"dependencies": {
3232
"@nuxt/kit": "^3.0.0-rc.12",
33+
"argon2": "^0.30.1",
3334
"dayjs": "^1.11.5",
3435
"defu": "^6.1.0",
3536
"h3": "^0.8.5",

src/module.ts

Lines changed: 20 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,33 @@
11
import { addImportsDir, addServerHandler, createResolver, defineNuxtModule, useLogger } from '@nuxt/kit'
22
import { CreateStorageOptions } from 'unstorage'
33
import { defu } from 'defu'
4-
5-
export type SameSiteOptions = 'lax' | 'strict' | 'none'
6-
export type SupportedSessionApiMethods = 'patch' | 'delete' | 'get' | 'post'
7-
8-
declare interface SessionOptions {
9-
/**
10-
* 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
11-
* @default 600
12-
* @example 30
13-
* @type number | null
14-
*/
15-
expiryInSeconds: number | null
16-
/**
17-
* How many characters the random session id should be long
18-
* @default 64
19-
* @example 128
20-
* @type number
21-
*/
22-
idLength: number
23-
/**
24-
* What prefix to use to store session information via `unstorage`
25-
* @default 64
26-
* @example 128
27-
* @type number
28-
* @docs https://github.com/unjs/unstorage
29-
*/
30-
storePrefix: string
31-
/**
32-
* When to attach session cookie to requests
33-
* @default 'lax'
34-
* @example 'strict'
35-
* @type SameSiteOptions
36-
* @docs https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
37-
*/
38-
cookieSameSite: SameSiteOptions
39-
/**
40-
* Driver configuration for session-storage. Per default in-memory storage is used
41-
* @default {}
42-
* @example { driver: redisDriver({ base: 'storage:' }) }
43-
* @type CreateStorageOptions
44-
* @docs https://github.com/unjs/unstorage
45-
*/
46-
storageOptions: CreateStorageOptions,
47-
/**
48-
* 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.
49-
* @default null
50-
* @example '.example.com'
51-
* @type string | null
52-
* @docs https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#define_where_cookies_are_sent
53-
*/
54-
domain: string | null
55-
}
56-
57-
declare interface ApiOptions {
58-
/**
59-
* 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.
60-
* @default true
61-
* @example false
62-
* @type boolean
63-
*/
64-
isEnabled: boolean
65-
/**
66-
* 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
67-
* an empty array will result in all API methods being registered. Disable the api via the `api.isEnabled` option.
68-
* @default []
69-
* @example ['get']
70-
* @type SupportedSessionApiMethods[]
71-
*/
72-
methods: SupportedSessionApiMethods[]
73-
/**
74-
* Base path of the session api.
75-
* @default /api/session
76-
* @example /_session
77-
* @type string
78-
*/
79-
basePath: string
80-
}
81-
82-
export interface ModuleOptions {
83-
/**
84-
* Whether to enable the module
85-
* @default true
86-
* @example true
87-
* @type boolean
88-
*/
89-
isEnabled: boolean,
90-
/**
91-
* Configure session-behvaior
92-
* @type SessionOptions
93-
*/
94-
session: Partial<SessionOptions>
95-
/**
96-
* Configure session-api and composable-behavior
97-
* @type ApiOptions
98-
*/
99-
api: Partial<ApiOptions>
100-
}
4+
import type {
5+
FilledModuleOptions,
6+
ModuleOptions,
7+
ModulePublicRuntimeConfig,
8+
SessionIpPinningOptions,
9+
SupportedSessionApiMethods
10+
} from './types'
10111

10212
const PACKAGE_NAME = 'nuxt-session'
10313

104-
const defaults: ModuleOptions = {
14+
const defaults: FilledModuleOptions = {
10515
isEnabled: true,
10616
session: {
10717
expiryInSeconds: 60 * 10,
10818
idLength: 64,
10919
storePrefix: 'sessions',
11020
cookieSameSite: 'lax',
21+
storageOptions: {} as CreateStorageOptions,
11122
domain: null,
112-
storageOptions: {}
23+
ipPinning: false as boolean|SessionIpPinningOptions
11324
},
11425
api: {
11526
isEnabled: true,
116-
methods: [],
27+
methods: [] as SupportedSessionApiMethods[],
11728
basePath: '/api/session'
11829
}
119-
}
30+
} as const
12031

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

14253
// 2. Set public and private runtime configuration
143-
const options = defu(moduleOptions, defaults)
54+
const options: FilledModuleOptions = defu(moduleOptions, defaults)
14455
options.api.methods = moduleOptions.api.methods.length > 0 ? moduleOptions.api.methods : ['patch', 'delete', 'get', 'post']
145-
nuxt.options.runtimeConfig.session = defu(nuxt.options.runtimeConfig.session, options)
146-
nuxt.options.runtimeConfig.public = defu(nuxt.options.runtimeConfig.public, { session: { api: options.api } })
56+
// @ts-ignore TODO: Fix this `nuxi prepare` bug (see https://github.com/nuxt/framework/issues/8728)
57+
nuxt.options.runtimeConfig.session = defu(nuxt.options.runtimeConfig.session, options) as FilledModuleOptions
58+
59+
const publicConfig: ModulePublicRuntimeConfig = { session: { api: options.api } }
60+
nuxt.options.runtimeConfig.public = defu(nuxt.options.runtimeConfig.public, publicConfig)
14761

14862
// 3. Locate runtime directory and transpile module
14963
const { resolve } = createResolver(import.meta.url)
@@ -174,3 +88,5 @@ export default defineNuxtModule<ModuleOptions>({
17488
logger.success('Session setup complete')
17589
}
17690
})
91+
92+
export * from './types'

src/runtime/composables/useSession.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useFetch, createError } from '#app'
22
import { nanoid } from 'nanoid'
33
import { Ref, ref } from 'vue'
4-
import type { SupportedSessionApiMethods } from '../../module'
5-
import type { Session } from '../server/middleware/session'
4+
import type { Session, SupportedSessionApiMethods } from '../../types'
65
import { useRuntimeConfig } from '#imports'
76

87
type SessionData = Record<string, any>

src/runtime/server/api/session.patch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const checkIfObjectAndContainsIllegalKeys = (shape: unknown): shape is Ob
66
}
77

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

1212
export default eventHandler(async (event) => {

src/runtime/server/api/session.post.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { eventHandler, readBody } from 'h3'
1+
import { createError, eventHandler, readBody } from 'h3'
22
import { checkIfObjectAndContainsIllegalKeys } from './session.patch'
33

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

1010
// Fully overwrite the session with body data, only keep sessions own properties (id, createdAt)
1111
event.context.session = {
12+
...body,
1213
id: event.context.session.id,
1314
createdAt: event.context.session.createdAt,
14-
...body
15+
ip: event.context.session.ip
1516
}
1617

1718
return event.context.session
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export class IpMismatch extends Error {
2+
constructor (message = 'User IP doesn\'t match the one in session') {
3+
super(message)
4+
}
5+
}
6+
7+
export class IpMissingFromSession extends Error {
8+
constructor (message = 'No IP in session even though ipPinning is enabled') {
9+
super(message)
10+
}
11+
}
12+
13+
export class SessionExpired extends Error {
14+
constructor (message = 'Session expired') {
15+
super(message)
16+
}
17+
}

src/runtime/server/middleware/session/index.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { H3Event, eventHandler, setCookie, parseCookies, deleteCookie } from 'h3'
1+
import { deleteCookie, eventHandler, H3Event, parseCookies, setCookie } from 'h3'
22
import { nanoid } from 'nanoid'
33
import dayjs from 'dayjs'
4-
import type { SameSiteOptions } from '../../../../module'
4+
import { SameSiteOptions, Session, SessionOptions } from '../../../../types'
55
import { dropStorageSession, getStorageSession, setStorageSession } from './storage'
6+
import { processSessionIp, getHashedIpAddress } from './ipPinning'
7+
import { SessionExpired } from './exceptions'
68
import { useRuntimeConfig } from '#imports'
79

810
const SESSION_COOKIE_NAME = 'sessionId'
@@ -16,13 +18,14 @@ const safeSetCookie = (event: H3Event, name: string, value: string) => setCookie
1618
// 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
1719
sameSite: useRuntimeConfig().session.session.cookieSameSite as SameSiteOptions,
1820
// Set cookie for subdomain
19-
domain: useRuntimeConfig().session.session.domain,
21+
domain: useRuntimeConfig().session.session.domain
2022
})
2123

22-
export declare interface Session {
23-
id: string
24-
createdAt: Date
25-
[key: string]: any
24+
const checkSessionExpirationTime = (session: Session, sessionExpiryInSeconds: number) => {
25+
const now = dayjs()
26+
if (now.diff(dayjs(session.createdAt), 'seconds') > sessionExpiryInSeconds) {
27+
throw new SessionExpired()
28+
}
2629
}
2730

2831
/**
@@ -56,15 +59,19 @@ export const deleteSession = async (event: H3Event) => {
5659
}
5760

5861
const newSession = async (event: H3Event) => {
59-
// Cleanup old session data to avoid leaks
60-
await deleteSession(event)
62+
const runtimeConfig = useRuntimeConfig()
63+
const sessionOptions = runtimeConfig.session.session
6164

6265
// (Re-)Set cookie
63-
const sessionId = nanoid(useRuntimeConfig().session.session.idLength)
66+
const sessionId = nanoid(sessionOptions.idLength)
6467
safeSetCookie(event, SESSION_COOKIE_NAME, sessionId)
6568

6669
// Store session data in storage
67-
const session: Session = { id: sessionId, createdAt: new Date() }
70+
const session: Session = {
71+
id: sessionId,
72+
createdAt: new Date(),
73+
ip: sessionOptions.ipPinning ? await getHashedIpAddress(event) : undefined
74+
}
6875
await setStorageSession(sessionId, session)
6976

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

86-
// 3. Is the session not expired?
87-
const sessionExpiryInSeconds = useRuntimeConfig().session.session.expiryInSeconds
88-
if (sessionExpiryInSeconds !== null) {
89-
const now = dayjs()
90-
if (now.diff(dayjs(session.createdAt), 'seconds') > sessionExpiryInSeconds) {
91-
return null
93+
const runtimeConfig = useRuntimeConfig()
94+
const sessionOptions = runtimeConfig.session.session as SessionOptions
95+
const sessionExpiryInSeconds = sessionOptions.expiryInSeconds
96+
97+
try {
98+
// 3. Is the session not expired?
99+
if (sessionExpiryInSeconds !== null) {
100+
checkSessionExpirationTime(session, sessionExpiryInSeconds)
101+
}
102+
103+
// 4. Check for IP pinning logic
104+
if (sessionOptions.ipPinning) {
105+
await processSessionIp(event, session)
92106
}
107+
} catch {
108+
await deleteSession(event) // Cleanup old session data to avoid leaks
109+
110+
return null
93111
}
94112

95113
return session
96114
}
97115

98116
function isSession (shape: unknown): shape is Session {
99-
if (typeof shape === 'object' && !!shape && 'id' in shape && 'createdAt' in shape) {
100-
return true
101-
}
102-
103-
return false
117+
return typeof shape === 'object' && !!shape && 'id' in shape && 'createdAt' in shape
104118
}
105119

106120
const ensureSession = async (event: H3Event) => {

0 commit comments

Comments
 (0)