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

fix: handle xfh properly and protect forms and json pipeline #83

Merged
merged 1 commit into from
Jun 14, 2022
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
5 changes: 5 additions & 0 deletions src/forms-pipe.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
import { PipelineResponse } from './PipelineResponse.js';
import fetchConfigAll from './steps/fetch-config-all.js';
import setCustomResponseHeaders from './steps/set-custom-response-headers.js';
import { authenticate } from './steps/authenticate.js';

function error(log, msg, status, response) {
log.error(msg);
Expand Down Expand Up @@ -96,6 +97,10 @@ export async function formsPipe(state, request) {
},
});
await fetchConfigAll(state, request, response);
await authenticate(state, request, response);
if (response.error) {
return response;
}
await setCustomResponseHeaders(state, request, response);

const {
Expand Down
2 changes: 2 additions & 0 deletions src/json-pipe.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import setCustomResponseHeaders from './steps/set-custom-response-headers.js';
import { PipelineResponse } from './PipelineResponse.js';
import jsonFilter from './utils/json-filter.js';
import { extractLastModified, updateLastModified } from './utils/last-modified.js';
import { authenticate } from './steps/authenticate.js';

/**
* Runs the default pipeline and returns the response.
Expand Down Expand Up @@ -80,6 +81,7 @@ export async function jsonPipe(state, req) {

// Load config-all and set response headers
await fetchConfigAll(state, req, response);
await authenticate(state, req, response);
await setCustomResponseHeaders(state, req, response);

return response;
Expand Down
11 changes: 8 additions & 3 deletions src/steps/authenticate.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function isAllowed(email = '', allows = []) {
*/
export async function authenticate(state, req, res) {
// get auth info
const authInfo = await getAuthInfo(state, req, res);
const authInfo = await getAuthInfo(state, req);

// check if `.auth` route to validate and exchange token
if (state.info.path === '/.auth') {
Expand All @@ -52,12 +52,17 @@ export async function authenticate(state, req, res) {

// if not authenticated, redirect to login screen
if (!authInfo.authenticated) {
// send 401 for plain requests
if (state.info.selector || state.type !== 'html') {
state.log.warn('[auth] unauthorized. redirect to login only for extension less html.');
res.status = 401;
res.error = 'unauthorized.';
return;
}
authInfo.redirectToLogin(state, req, res);
return;
}

// console.log(authInfo.profile);

// check profile is allowed
const { allow } = state.config.access;
const allows = Array.isArray(allow) ? allow : [allow];
Expand Down
14 changes: 13 additions & 1 deletion src/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,19 @@ export class AuthInfo {

// determine the location of 'this' document based on the xfh header. so that logins to
// .page stay on .page. etc. but fallback to the config.host if non set
const host = req.headers.get('x-forwarded-host') || state.config.host;
let host = req.headers.get('x-forwarded-host');
if (host) {
host = host.split(',')[0].trim();
}
if (!host) {
host = state.config.host;
}
if (!host) {
log.error('[auth] unable to create login redirect: no xfh or config.host.');
res.status = 401;
res.error = 'no host information.';
return;
}

const url = new URL(idp.discovery.authorization_endpoint);

Expand Down
9 changes: 9 additions & 0 deletions test/forms-pipe.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,15 @@ describe('Form POST Requests', () => {
assert.strictEqual(resp.status, 400);
});

it('reject unauthorized.', async () => {
const req = new PipelineRequest('https://helix-pipeline.com/', defaultRequest);
const state = new PipelineState(defaultState());
state.config.access = { allow: '*@adobe.com' };
state.s3Loader = new StaticS3Loader();
const resp = await formsPipe(state, req);
assert.strictEqual(resp.status, 401);
});

describe('extractBodyData', () => {
const validBody = {
data: {
Expand Down
7 changes: 7 additions & 0 deletions test/json-pipe.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,13 @@ describe('JSON Pipe Test', () => {
assert.strictEqual(resp.headers.get('x-error'), 'multisheet data invalid. missing ":names" property.');
});

it('rejects unauthorized', async () => {
const state = createDefaultState();
state.config.access = { allow: '*@adobe.com' };
const resp = await jsonPipe(state, new PipelineRequest('https://json-filter.com/?limit=10'));
assert.strictEqual(resp.status, 401);
});

it('creates correct filter with no offset', async () => {
const state = createDefaultState();
const resp = await jsonPipe(state, new PipelineRequest('https://json-filter.com/?limit=10'));
Expand Down
20 changes: 20 additions & 0 deletions test/steps/authenticate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ describe('Authenticate Test', () => {
assert.strictEqual(res.status, 302);
});

it('send 401 for unauthenticated .plain requests', async () => {
const { authenticate: authProxy } = await esmock('../../src/steps/authenticate.js', {
'../../src/utils/auth.js': {
getAuthInfo: () => ({
redirectToLogin(state, req, res) {
res.status = 302;
},
}),
},
});
const state = new PipelineState({ path: '/nav.plain.html' });
state.config.access = {
allow: '*@adobe.com',
};
const req = new PipelineRequest('https://localhost');
const res = new PipelineResponse();
await authProxy(state, req, res);
assert.strictEqual(res.status, 401);
});

it('.auth fetches the token', async () => {
const { authenticate: authProxy } = await esmock('../../src/steps/authenticate.js', {
'../../src/utils/auth.js': {
Expand Down
60 changes: 58 additions & 2 deletions test/utils/auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
/* eslint-env mocha */
import assert from 'assert';
import {
generateKeyPair, exportJWK, SignJWT, UnsecuredJWT,
generateKeyPair, exportJWK, SignJWT, UnsecuredJWT, decodeJwt,
} from 'jose';
import {
getAuthInfo,
Expand Down Expand Up @@ -310,6 +310,7 @@ describe('AuthInfo tests', () => {
.withIdp(idpFakeTestIDP);

const state = new PipelineState({});
state.config.host = 'www.hlx.live';
const req = new PipelineRequest('https://localhost');
const res = new PipelineResponse();
await authInfo.redirectToLogin(state, req, res);
Expand All @@ -327,10 +328,65 @@ describe('AuthInfo tests', () => {
redirect_uri: 'https://login.hlx.page/.auth',
response_type: 'code',
scope: 'openid profile email',
state: 'eyJhbGciOiJub25lIn0.eyJyZXF1ZXN0UGF0aCI6Ii8ifQ.',
state: 'eyJhbGciOiJub25lIn0.eyJyZXF1ZXN0UGF0aCI6Ii8iLCJyZXF1ZXN0SG9zdCI6Ind3dy5obHgubGl2ZSJ9.',
});
});

it('redirects to the login page (xfh)', async () => {
const authInfo = AuthInfo
.Default()
.withIdp(idpFakeTestIDP);

const state = new PipelineState({});
const req = new PipelineRequest('https://localhost', {
headers: {
'x-forwarded-host': 'www.hlx.live',
},
});
const res = new PipelineResponse();
await authInfo.redirectToLogin(state, req, res);
assert.strictEqual(res.status, 302);
const reqState = new URL(res.headers.get('location')).searchParams.get('state');
assert.deepStrictEqual(decodeJwt(reqState), {
requestHost: 'www.hlx.live',
requestPath: '/',
});
});

it('redirects to the login page (xfh - multi)', async () => {
const authInfo = AuthInfo
.Default()
.withIdp(idpFakeTestIDP);

const state = new PipelineState({ path: '/en/blog' });
const req = new PipelineRequest('https://localhost/', {
headers: {
'x-forwarded-host': 'bla.live, foo.page',
},
});
const res = new PipelineResponse();
await authInfo.redirectToLogin(state, req, res);
assert.strictEqual(res.status, 302);
const reqState = new URL(res.headers.get('location')).searchParams.get('state');
assert.deepStrictEqual(decodeJwt(reqState), {
requestHost: 'bla.live',
requestPath: '/en/blog',
});
});

it('redirects fails if not host', async () => {
const authInfo = AuthInfo
.Default()
.withIdp(idpFakeTestIDP);

const state = new PipelineState({});
const req = new PipelineRequest('https://localhost/');
const res = new PipelineResponse();
await authInfo.redirectToLogin(state, req, res);
assert.strictEqual(res.status, 401);
assert.strictEqual(res.error, 'no host information.');
});

it('redirects to the login page needs client id', async () => {
const authInfo = AuthInfo
.Default()
Expand Down