diff --git a/examples/default-middlewares-example/app.js b/examples/default-middlewares-example/app.js new file mode 100644 index 00000000..55078475 --- /dev/null +++ b/examples/default-middlewares-example/app.js @@ -0,0 +1,16 @@ +const { orka } = require('../../build'); + +const w = orka({ + diamorphosis: { configFolder: './examples/default-middlewares-example' }, + routesPath: './examples/default-middlewares-example/routes.js', + beforeStart: () => { + const config = require('./config'); + console.log(`Going to start env: ${config.nodeEnv}`); + } +}); + +if (!module.parent) { + w.start(); +} + +module.exports = w; diff --git a/examples/default-middlewares-example/config.js b/examples/default-middlewares-example/config.js new file mode 100644 index 00000000..7a1232a8 --- /dev/null +++ b/examples/default-middlewares-example/config.js @@ -0,0 +1,26 @@ +module.exports = { + nodeEnv: 'demo', + log: { + json: true + }, + app: { + name: 'foo' + }, + cors: { + publicPrefixes: ['/api/allowAll'], + allowedOrigins: ['localhost:3220', 'lvh.me', '*.lvh.me'], + }, + riviere: { + bodyKeysRegex: '.*' + }, + visitor: { + setCookie: true, + cookie: 'wmc', + maxAge: '7d', + variant: 'v1', + secure: true, + forwarded_headers: ['user-agent', 'x-request-id', 'referer', 'origin'], + waitForAttribution: false, + }, + port: 3220 +}; diff --git a/examples/default-middlewares-example/routes.js b/examples/default-middlewares-example/routes.js new file mode 100644 index 00000000..ee902705 --- /dev/null +++ b/examples/default-middlewares-example/routes.js @@ -0,0 +1,5 @@ +module.exports = { + get: { + '/test': async (ctx, next) => (ctx.body = 'ok') + } +}; diff --git a/src/initializers/koa/add-visitor-id.ts b/src/initializers/koa/add-visitor-id.ts index ba4a1352..b12bd6e7 100644 --- a/src/initializers/koa/add-visitor-id.ts +++ b/src/initializers/koa/add-visitor-id.ts @@ -38,13 +38,9 @@ export default function (config): Middleware { ctx.cookies.secure = true; } - let cookieDomain = domain((ctx.origin && new URL(ctx.origin).hostname) || ctx.hostname); - if (config.visitor.getCookieDomain && typeof config.visitor.getCookieDomain === 'function') { - cookieDomain = config.visitor.getCookieDomain(ctx); - } const options: SetOption = { - domain: cookieDomain, + domain: getCookieDomain(ctx, config), maxAge: config.visitor.maxAge && ms(config.visitor.maxAge), httpOnly: false, secure: config.visitor.secure, @@ -59,4 +55,23 @@ export default function (config): Middleware { } return next(); }; + + function getCookieDomain(ctx: Context, config): string { + // custom cb function takes precedence + if (config.visitor.getCookieDomain && typeof config.visitor.getCookieDomain === 'function') { + return config.visitor.getCookieDomain(ctx); + } + + // utilizes the original origin + // even when app is behind proxy + // https://github.com/koajs/koa/issues/1008#issuecomment-2872014479 + if (URL.canParse(ctx.request.URL.origin)) { + return domain(ctx.request.URL.hostname); + } else { + // get hostname from Host header + // or X-Forwarded-Host when is proxy enabled + // https://github.com/koajs/koa/blob/master/lib/request.js#L281 + return domain(ctx.hostname); + } + } } diff --git a/test/examples/default-middlewares-example.test.ts b/test/examples/default-middlewares-example.test.ts new file mode 100644 index 00000000..7bbc7e17 --- /dev/null +++ b/test/examples/default-middlewares-example.test.ts @@ -0,0 +1,134 @@ +import * as should from 'should'; +import * as supertest from 'supertest'; +import * as uuid from 'uuid'; + +describe('Default middlewares examples', () => { + let server; + after(async () => { + if (server) await server.stop(); + }); + + before(async () => { + const serverPath = '../../examples/default-middlewares-example/app'; + delete require.cache[require.resolve(serverPath)]; + server = require(serverPath); + await server.start(); + await new Promise(t => setTimeout(t, 1000)); + }); + + describe('add-visitor-id middleware', function() { + it('/test sets a visitor cookie', async () => { + const response = await supertest('localhost:3220') + .get('/test') + .expect(200); + + const cookies = response.headers['set-cookie']; + should(cookies).not.be.undefined(); + + const visitorCookie = cookies.find(cookie => cookie.startsWith('wmc=')); + should(visitorCookie).not.be.undefined(); + visitorCookie.should.match(/domain=localhost/i); + visitorCookie.should.match(/secure/i); + visitorCookie.should.match(/samesite=none/i); + + const [, encoded] = visitorCookie.split(';')[0].split('='); + const decoded = JSON.parse(decodeURIComponent(encoded)); + uuid.validate(decoded.cookie_id).should.be.true(); + }); + + it('/test reuses the visitor cookie when already present', async () => { + const visitor = uuid.v4(); + const cookie = encodeURIComponent(JSON.stringify({ cookie_id: visitor })); + + const response = await supertest('localhost:3220') + .get('/test') + .set('Cookie', `wmc=${cookie}`) + .expect(200); + + should(response.headers['set-cookie']).be.undefined(); + }); + + describe('when the Origin header is malformed', function() { + it('/test sets a visitor cookie', async () => { + const response = await supertest('localhost:3220') + .get('/test') + .set('origin', 'invalid') + .expect(200); + + const cookies = response.headers['set-cookie']; + should(cookies).not.be.undefined(); + + const visitorCookie = cookies.find(cookie => cookie.startsWith('wmc=')); + should(visitorCookie).not.be.undefined(); + visitorCookie.should.match(/domain=localhost/i); + visitorCookie.should.match(/secure/i); + visitorCookie.should.match(/samesite=none/i); + + const [, encoded] = visitorCookie.split(';')[0].split('='); + const decoded = JSON.parse(decodeURIComponent(encoded)); + uuid.validate(decoded.cookie_id).should.be.true(); + }); + + it('derives the cookie domain from the host, not from Origin', async () => { + const response = await supertest('localhost:3220') + .get('/test') + .set('origin', 'https://evil.example.com') + .expect(200); + const visitorCookie = response.headers['set-cookie'].find(c => c.startsWith('wmc=')); + should(visitorCookie).not.be.undefined(); + visitorCookie.should.match(/domain=localhost/i); + visitorCookie.should.not.match(/evil\.example\.com/i); + }); + }); + + describe('when callback for cookie domain is provided from config', function() { + before(function() { + server.config.visitor.getCookieDomain = () => 'domain-provider-cb'; + }); + + after(function() { + delete server.config.visitor.getCookieDomain; + }); + + it('uses the callback to set a visitor cookie domain', async () => { + const response = await supertest('localhost:3220') + .get('/test') + .set('origin', 'invalid') + .expect(200); + + const cookies = response.headers['set-cookie']; + should(cookies).not.be.undefined(); + + const visitorCookie = cookies.find(cookie => cookie.startsWith('wmc=')); + should(visitorCookie).not.be.undefined(); + visitorCookie.should.match(/domain=domain-provider-cb/i); + visitorCookie.should.match(/secure/i); + visitorCookie.should.match(/samesite=none/i); + + const [, encoded] = visitorCookie.split(';')[0].split('='); + const decoded = JSON.parse(decodeURIComponent(encoded)); + uuid.validate(decoded.cookie_id).should.be.true(); + }); + + it('precedes the origin header uses the callback to set a visitor cookie', async () => { + const response = await supertest('localhost:3220') + .get('/test') + .set('origin', 'http://localhost:3220') + .expect(200); + + const cookies = response.headers['set-cookie']; + should(cookies).not.be.undefined(); + + const visitorCookie = cookies.find(cookie => cookie.startsWith('wmc=')); + should(visitorCookie).not.be.undefined(); + visitorCookie.should.match(/domain=domain-provider-cb/i); + visitorCookie.should.match(/secure/i); + visitorCookie.should.match(/samesite=none/i); + + const [, encoded] = visitorCookie.split(';')[0].split('='); + const decoded = JSON.parse(decodeURIComponent(encoded)); + uuid.validate(decoded.cookie_id).should.be.true(); + }); + }); + }); +}); diff --git a/test/initializers/koa/add-visitor-id.test.ts b/test/initializers/koa/add-visitor-id.test.ts index bbd67023..bea62a00 100644 --- a/test/initializers/koa/add-visitor-id.test.ts +++ b/test/initializers/koa/add-visitor-id.test.ts @@ -16,7 +16,7 @@ describe('add-visitor-id', function () { beforeEach(function () { getRequestContextStub = sandbox.stub(); ctx = { - origin: 'https://apply.workable.com/foo', + request: { URL: new URL('https://apply.workable.com/foo') }, state: {}, cookies: { get: () => encodeURIComponent(JSON.stringify({ cookie_id: visitor }))