Skip to content

Commit

Permalink
Make SameSite cookie's attribute configurable (#68108)
Browse files Browse the repository at this point in the history
* support 'SameSite: None' in http service

* add tests

* allow to configure SameSite attribute for security cookie

* update docs

* fix test suite name

* remove false from samesite options

* document xpack.security.sameSiteCookies

* address comments

* address comments

Co-authored-by: Elastic Machine <[email protected]>
  • Loading branch information
mshustov and elasticmachine authored Jun 12, 2020
1 parent 679118b commit 8cdc2a1
Show file tree
Hide file tree
Showing 8 changed files with 114 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export interface SessionStorageCookieOptions<T>
| [encryptionKey](./kibana-plugin-core-server.sessionstoragecookieoptions.encryptionkey.md) | <code>string</code> | A key used to encrypt a cookie's value. Should be at least 32 characters long. |
| [isSecure](./kibana-plugin-core-server.sessionstoragecookieoptions.issecure.md) | <code>boolean</code> | Flag indicating whether the cookie should be sent only via a secure connection. |
| [name](./kibana-plugin-core-server.sessionstoragecookieoptions.name.md) | <code>string</code> | Name of the session cookie. |
| [sameSite](./kibana-plugin-core-server.sessionstoragecookieoptions.samesite.md) | <code>'Strict' &#124; 'Lax' &#124; 'None'</code> | Defines SameSite attribute of the Set-Cookie Header. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite |
| [validate](./kibana-plugin-core-server.sessionstoragecookieoptions.validate.md) | <code>(sessionValue: T &#124; T[]) =&gt; SessionCookieValidationResult</code> | Function called to validate a cookie's decrypted value. |

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->

[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SessionStorageCookieOptions](./kibana-plugin-core-server.sessionstoragecookieoptions.md) &gt; [sameSite](./kibana-plugin-core-server.sessionstoragecookieoptions.samesite.md)

## SessionStorageCookieOptions.sameSite property

Defines SameSite attribute of the Set-Cookie Header. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite

<b>Signature:</b>

```typescript
sameSite?: 'Strict' | 'Lax' | 'None';
```
5 changes: 5 additions & 0 deletions docs/settings/security-settings.asciidoc
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ You can configure the following settings in the `kibana.yml` file.
this to `true` if SSL is configured outside of {kib} (for example, you are
routing requests through a load balancer or proxy).

| `xpack.security.sameSiteCookies`
| Sets the `SameSite` attribute of the session cookie. This allows you to declare whether your cookie should be restricted to a first-party or same-site context.
Valid values are `Strict`, `Lax`, `None`.
This is *not set* by default, which modern browsers will treat as `Lax`. If you use Kibana embedded in an iframe in modern browsers, you might need to set it to `None`. Setting this value to `None` requires cookies to be sent over a secure connection by setting `xpack.security.secureCookies: true`.

| `xpack.security.session.idleTimeout`
| Sets the session duration. By default, sessions stay active until the
browser is closed. When this is set to an explicit idle timeout, closing the
Expand Down
56 changes: 56 additions & 0 deletions src/core/server/http/cookie_session_storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,4 +424,60 @@ describe('Cookie based SessionStorage', () => {
]);
});
});

describe('#options', () => {
describe('#SameSite', () => {
it('throws an exception if "SameSite: None" set on not Secure connection', async () => {
const { server: innerServer } = await server.setup(setupDeps);

expect(
createCookieSessionStorageFactory(logger.get(), innerServer, {
...cookieOptions,
sameSite: 'None',
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"\\"SameSite: None\\" requires Secure connection"`
);
});

for (const sameSite of ['Strict', 'Lax', 'None'] as const) {
it(`sets and parses SameSite = ${sameSite} correctly`, async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('');

router.get({ path: '/', validate: false }, async (context, req, res) => {
const sessionStorage = factory.asScoped(req);
const sessionValue = await sessionStorage.get();
if (!sessionValue) {
sessionStorage.set(sessVal());
return res.ok();
}
return res.ok({ body: { value: sessionValue.value } });
});

const factory = await createCookieSessionStorageFactory(logger.get(), innerServer, {
...cookieOptions,
isSecure: true,
name: `sid-${sameSite}`,
sameSite,
});
await server.start();

const response = await supertest(innerServer.listener).get('/').expect(200);

const cookies = response.get('set-cookie');
expect(cookies).toBeDefined();
expect(cookies).toHaveLength(1);

const sessionCookie = retrieveSessionCookie(cookies[0]);
expect(sessionCookie.extensions).toContain(`SameSite=${sameSite}`);

await supertest(innerServer.listener)
.get('/')
.set('Cookie', `${sessionCookie.key}=${sessionCookie.value}`)
.expect(200, { value: userData });
});
}
});
});
});
36 changes: 34 additions & 2 deletions src/core/server/http/cookie_session_storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import { Request, Server } from 'hapi';
import hapiAuthCookie from 'hapi-auth-cookie';
// @ts-ignore no TS definitions
import Statehood from 'statehood';

import { KibanaRequest, ensureRawRequest } from './router';
import { SessionStorageFactory, SessionStorage } from './session_storage';
Expand All @@ -45,6 +47,11 @@ export interface SessionStorageCookieOptions<T> {
* Flag indicating whether the cookie should be sent only via a secure connection.
*/
isSecure: boolean;
/**
* Defines SameSite attribute of the Set-Cookie Header.
* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite
*/
sameSite?: 'Strict' | 'Lax' | 'None';
}

/**
Expand Down Expand Up @@ -100,6 +107,12 @@ class ScopedCookieSessionStorage<T extends Record<string, any>> implements Sessi
}
}

function validateOptions(options: SessionStorageCookieOptions<any>) {
if (options.sameSite === 'None' && options.isSecure !== true) {
throw new Error('"SameSite: None" requires Secure connection');
}
}

/**
* Creates SessionStorage factory, which abstract the way of
* session storage implementation and scoping to the incoming requests.
Expand All @@ -113,10 +126,12 @@ export async function createCookieSessionStorageFactory<T>(
cookieOptions: SessionStorageCookieOptions<T>,
basePath?: string
): Promise<SessionStorageFactory<T>> {
validateOptions(cookieOptions);

function clearInvalidCookie(req: Request | undefined, path: string = basePath || '/') {
// if the cookie did not include the 'path' attribute in the session value, it is a legacy cookie
// we will assume that the cookie was created with the current configuration
log.debug(`Clearing invalid session cookie`);
log.debug('Clearing invalid session cookie');
// need to use Hapi toolkit to clear cookie with defined options
if (req) {
(req.cookieAuth as any).h.unstate(cookieOptions.name, { path });
Expand All @@ -139,9 +154,26 @@ export async function createCookieSessionStorageFactory<T>(
path: basePath,
clearInvalid: false,
isHttpOnly: true,
isSameSite: false,
isSameSite: cookieOptions.sameSite === 'None' ? false : cookieOptions.sameSite ?? false,
});

// A hack to support SameSite: 'None'.
// Remove it after update Hapi to v19 that supports SameSite: 'None' out of the box.
if (cookieOptions.sameSite === 'None') {
log.debug('Patching Statehood.prepareValue');
const originalPrepareValue = Statehood.prepareValue;
Statehood.prepareValue = function kibanaStatehoodPrepareValueWrapper(
name: string,
value: unknown,
options: any
) {
if (name === cookieOptions.name) {
options.isSameSite = cookieOptions.sameSite;
}
return originalPrepareValue(name, value, options);
};
}

return {
asScoped(request: KibanaRequest) {
return new ScopedCookieSessionStorage<T>(log, server, ensureRawRequest(request));
Expand Down
1 change: 1 addition & 0 deletions src/core/server/server.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2429,6 +2429,7 @@ export interface SessionStorageCookieOptions<T> {
encryptionKey: string;
isSecure: boolean;
name: string;
sameSite?: 'Strict' | 'Lax' | 'None';
validate: (sessionValue: T | T[]) => SessionCookieValidationResult;
}

Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/security/server/authentication/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export async function setupAuthentication({
encryptionKey: config.encryptionKey,
isSecure: config.secureCookies,
name: config.cookieName,
sameSite: config.sameSiteCookies,
validate: (session: ProviderSession | ProviderSession[]) => {
const array: ProviderSession[] = Array.isArray(session) ? session : [session];
for (const sess of array) {
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/security/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ export const ConfigSchema = schema.object({
lifespan: schema.nullable(schema.duration()),
}),
secureCookies: schema.boolean({ defaultValue: false }),
sameSiteCookies: schema.maybe(
schema.oneOf([schema.literal('Strict'), schema.literal('Lax'), schema.literal('None')])
),
authc: schema.object({
selector: schema.object({ enabled: schema.maybe(schema.boolean()) }),
providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], {
Expand Down

0 comments on commit 8cdc2a1

Please sign in to comment.