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

CSRF token: handle failed url-decoding #1265

Merged
merged 5 commits into from
Nov 9, 2024
Merged
Show file tree
Hide file tree
Changes from 4 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
8 changes: 4 additions & 4 deletions lib/http/preprocessors.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

const { verifyPassword, isValidToken } = require('../util/crypto');
const { isBlank, isPresent, noop, without } = require('../util/util');
const { isTrue } = require('../util/http');
const { isTrue, urlDecode } = require('../util/http');
const oidc = require('../util/oidc');
const Problem = require('../util/problem');
const { QueryOptions } = require('../util/db');
Expand Down Expand Up @@ -122,10 +122,10 @@ const authHandler = ({ Sessions, Users, Auth }, context) => {
// if authentication failed anyway, just do nothing.
if ((cxt == null) || !cxt.auth.session.isDefined()) return;

// if csrf missing or mismatch; fail outright.
const csrf = cxt.body.__csrf;
if (isBlank(csrf) || (cxt.auth.session.get().csrf !== decodeURIComponent(csrf)))
const csrf = urlDecode(cxt.body.__csrf);
if (csrf.isEmpty() || isBlank(csrf.get()) || (cxt.auth.session.get().csrf !== csrf.get())) {
return reject(Problem.user.authenticationFailed());
}

// delete the token off the body so it doesn't mess with downstream
// payload expectations.
Expand Down
6 changes: 5 additions & 1 deletion lib/util/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
// Helper functions that relate to the HTTP/service layer of the application.

const { parse, URL } = require('url');
const { map, tryCatch } = require('ramda');
const { isBlank } = require('./util');
const Option = require('./option');
const sanitize = require('sanitize-filename');


Expand All @@ -24,6 +26,8 @@ const isFalse = (x) => (!isBlank(x) && typeof x === 'string' && (x.toLowerCase()
// Returns just the pathname of the URL, omitting querystring and other non-path decoration.
const urlPathname = (x) => parse(x).pathname;

const urlDecode = (str) => tryCatch(map(Option.of, decodeURIComponent), Option.none)(str);
alxndrsn marked this conversation as resolved.
Show resolved Hide resolved


////////////////////////////////////////////////////////////////////////////////
// OUTPUT HELPERS
Expand Down Expand Up @@ -128,7 +132,7 @@ const withEtag = (serverEtag, fn) => (request, response) => {
module.exports = {
isTrue, isFalse, urlPathname,
success, contentType, xml, atom, json, contentDisposition, redirect,
urlWithQueryParams, url,
urlWithQueryParams, url, urlDecode,
withEtag
};

12 changes: 12 additions & 0 deletions test/unit/http/preprocessors.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,18 @@ describe('preprocessors', () => {
)
)).should.be.rejectedWith(Problem, { problemCode: 401.2 }));

it('should reject cookie auth with invalid CSRF token for non-GET requests', () =>
Promise.resolve(authHandler(
{ Auth, Sessions: mockSessionsWithCsrf('alohomora', 'secretcsrf') },
new Context(
createRequest({ method: 'POST', headers: {
'X-Forwarded-Proto': 'https',
Cookie: 'session=alohomora'
}, body: { __csrf: '%ea' }, cookies: { session: 'alohomora' } }),
{ fieldKey: Option.none() }
)
)).should.be.rejectedWith(Problem, { problemCode: 401.2 }));

it('should do nothing on cookie auth with incorrect session token for non-GET requests', () =>
Promise.resolve(authHandler(
{ Auth, Sessions: mockSessionsWithCsrf('alohomora', 'secretcsrf') },
Expand Down
29 changes: 29 additions & 0 deletions test/unit/util/http.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const appRoot = require('app-root-path');
const http = require(appRoot + '/lib/util/http');
const Option = require(appRoot + '/lib/util/option');

describe('util/http', () => {
describe('isTrue', () => {
Expand Down Expand Up @@ -96,5 +97,33 @@ describe('util/http', () => {
urlWithQueryParams('/path?x=1&y=2&z=3', { x: null, z: undefined }).should.equal('/path?y=2');
});
});

describe('urlDecode()', () => {
const { urlDecode } = http;

[
[ '', '' ],
[ '%20', ' ' ],
[ 'abc123', 'abc123' ],
].forEach(([ decodable, expected ]) => {
it(`should successfully decode '${decodable}' to Option.of('${expected}')`, () => {
const decoded = urlDecode(decodable);
(decoded instanceof Option).should.equal(true);
decoded.isDefined().should.equal(true);
decoded.get().should.equal(expected);
});
});

[
'%',
'%ae',
].forEach(undecodable => {
it(`should decode '${undecodable}' to Option.None`, () => {
const decoded = urlDecode(undecodable);
(decoded instanceof Option).should.equal(true);
decoded.isEmpty().should.equal(true);
});
});
});
});