From 330e6fb3154d620b7d3cbd6b58467d182ca4a669 Mon Sep 17 00:00:00 2001 From: Uzlopak Date: Tue, 6 Dec 2022 12:24:52 +0100 Subject: [PATCH] RFC6717 conformancy (#112) * integrate basic-auth * make credentials more strict * add strict option for fallback opportunity * modify strictness * handle charset auth-token in challenge properly * add options to readme * Update README.md Co-authored-by: James Sumners * rename strict to strictCredentials * Update README.md Co-authored-by: Manuel Spigolon Co-authored-by: James Sumners Co-authored-by: Manuel Spigolon --- README.md | 17 ++ index.js | 141 +++++++++--- package.json | 1 - test/index.test.js | 514 +++++++++++++++++++++++++++++++++++++++++- types/index.d.ts | 2 + types/index.test-d.ts | 12 +- 6 files changed, 649 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 40f85d8..77a3c1b 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,23 @@ fastify.setErrorHandler(function (err, req, reply) { ## Options +### `utf8` (optional, default: true) + +User-ids or passwords containing characters outside the US-ASCII +character set will cause interoperability issues, unless both +communication partners agree on what character encoding scheme is to +be used. If utf8 is set to true the server will send the 'charset' parameter +to indicate a preference of "UTF-8", increasing the probability that +clients will switch to that encoding. + +### `strictCredentials` (optional, default: true) + +If strictCredentials is set to false the authorization header can contain +additional whitespaces at the beginning, in the midde and at the end of the +authorization header. +This is a fallback option to ensure the same behaviour as `@fastify/basic-auth` +version <=5.x. + ### `validate` (required) The `validate` function is called on each request made, diff --git a/index.js b/index.js index 7b3c5bc..b7b8bab 100644 --- a/index.js +++ b/index.js @@ -1,7 +1,6 @@ 'use strict' const fp = require('fastify-plugin') -const auth = require('basic-auth') const createError = require('@fastify/error') const MissingOrBadAuthorizationHeader = createError( @@ -10,25 +9,117 @@ const MissingOrBadAuthorizationHeader = createError( 401 ) +/** + * HTTP provides a simple challenge-response authentication framework + * that can be used by a server to challenge a client request and by a + * client to provide authentication information. It uses a case- + * insensitive token as a means to identify the authentication scheme, + * followed by additional information necessary for achieving + * authentication via that scheme. + * + * @see https://datatracker.ietf.org/doc/html/rfc7235#section-2.1 + * + * The scheme name is "Basic". + * @see https://datatracker.ietf.org/doc/html/rfc7617#section-2 + */ +const authScheme = '(?:[Bb][Aa][Ss][Ii][Cc])' +/** + * The BWS rule is used where the grammar allows optional whitespace + * only for historical reasons. A sender MUST NOT generate BWS in + * messages. A recipient MUST parse for such bad whitespace and remove + * it before interpreting the protocol element. + * + * @see https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.3 + */ +const BWS = '[ \t]' +/** + * The token68 syntax allows the 66 unreserved URI characters + * ([RFC3986]), plus a few others, so that it can hold a base64, + * base64url (URL and filename safe alphabet), base32, or base16 (hex) + * encoding, with or without padding, but excluding whitespace + * ([RFC4648]). + * @see https://datatracker.ietf.org/doc/html/rfc7235#section-2.1 + */ +const token68 = '([A-Za-z0-9._~+/-]+=*)' + +/** + * @see https://datatracker.ietf.org/doc/html/rfc7235#appendix-C + */ +const credentialsStrictRE = new RegExp(`^${authScheme} ${token68}$`) + +const credentialsLaxRE = new RegExp(`^${BWS}*${authScheme}${BWS}+${token68}${BWS}*$`) + +/** + * @see https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1 + */ +const CTL = '[\x00-\x1F\x7F]' +const controlRE = new RegExp(CTL) + +/** + * RegExp for basic auth user/pass + * + * user-pass = userid ":" password + * userid = * + * password = *TEXT + */ + +const userPassRE = /^([^:]*):(.*)$/ + async function fastifyBasicAuth (fastify, opts) { if (typeof opts.validate !== 'function') { throw new Error('Basic Auth: Missing validate function') } - const authenticateHeader = getAuthenticateHeader(opts.authenticate) + + const strictCredentials = opts.strictCredentials ?? true + const useUtf8 = opts.utf8 ?? true + const charset = useUtf8 ? 'utf-8' : 'ascii' + const authenticateHeader = getAuthenticateHeader(opts.authenticate, useUtf8) const header = (opts.header && opts.header.toLowerCase()) || 'authorization' + const credentialsRE = strictCredentials + ? credentialsStrictRE + : credentialsLaxRE + const validate = opts.validate.bind(fastify) fastify.decorate('basicAuth', basicAuth) function basicAuth (req, reply, next) { - const credentials = auth.parse(req.headers[header]) - if (credentials == null) { + const credentials = req.headers[header] + + if (typeof credentials !== 'string') { done(new MissingOrBadAuthorizationHeader()) - } else { - const result = validate(credentials.name, credentials.pass, req, reply, done) - if (result && typeof result.then === 'function') { - result.then(done, done) - } + return + } + + // parse header + const match = credentialsRE.exec(credentials) + if (match === null) { + done(new MissingOrBadAuthorizationHeader()) + return + } + + // decode user pass + const credentialsDecoded = Buffer.from(match[1], 'base64').toString(charset) + + /** + * The user-id and password MUST NOT contain any control characters (see + * "CTL" in Appendix B.1 of [RFC5234]). + * @see https://datatracker.ietf.org/doc/html/rfc7617#section-2 + */ + if (controlRE.test(credentialsDecoded)) { + done(new MissingOrBadAuthorizationHeader()) + return + } + + const userPass = userPassRE.exec(credentialsDecoded) + if (userPass === null) { + done(new MissingOrBadAuthorizationHeader()) + return + } + + const result = validate(userPass[1], userPass[2], req, reply, done) + if (result && typeof result.then === 'function') { + result.then(done, done) } function done (err) { @@ -39,14 +130,7 @@ async function fastifyBasicAuth (fastify, opts) { } if (err.statusCode === 401) { - switch (typeof authenticateHeader) { - case 'string': - reply.header('WWW-Authenticate', authenticateHeader) - break - case 'function': - reply.header('WWW-Authenticate', authenticateHeader(req)) - break - } + reply.header('WWW-Authenticate', authenticateHeader(req)) } next(err) } else { @@ -56,24 +140,29 @@ async function fastifyBasicAuth (fastify, opts) { } } -function getAuthenticateHeader (authenticate) { - if (!authenticate) return false +function getAuthenticateHeader (authenticate, useUtf8) { + if (!authenticate) return () => false if (authenticate === true) { - return 'Basic' + return useUtf8 + ? () => 'Basic charset="UTF-8"' + : () => 'Basic' } if (typeof authenticate === 'object') { const realm = authenticate.realm switch (typeof realm) { case 'undefined': - return 'Basic' case 'boolean': - return 'Basic' + return useUtf8 + ? () => 'Basic charset="UTF-8"' + : () => 'Basic' case 'string': - return `Basic realm="${realm}"` + return useUtf8 + ? () => `Basic realm="${realm}", charset="UTF-8"` + : () => `Basic realm="${realm}"` case 'function': - return function (req) { - return `Basic realm="${realm(req)}"` - } + return useUtf8 + ? (req) => `Basic realm="${realm(req)}", charset="UTF-8"` + : (req) => `Basic realm="${realm(req)}"` } } diff --git a/package.json b/package.json index 402c919..d0d07dc 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ }, "dependencies": { "@fastify/error": "^3.0.0", - "basic-auth": "^2.0.1", "fastify-plugin": "^4.0.0" }, "publishConfig": { diff --git a/test/index.test.js b/test/index.test.js index 4adb633..be4966e 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -42,6 +42,91 @@ test('Basic', t => { }) }) +test('Basic utf8: true', t => { + t.plan(2) + + const fastify = Fastify() + fastify.register(basicAuth, { validate, utf8: true }) + + function validate (username, password, req, res, done) { + if (username === 'test' && password === '123\u00A3') { + done() + } else { + done(new Error('Unauthorized')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + /** + * @see https://datatracker.ietf.org/doc/html/rfc7617#section-2.1 + */ + authorization: 'Basic dGVzdDoxMjPCow==' + } + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 200) + }) +}) + +test('Basic - 401, sending utf8 credentials base64 but utf8: false', t => { + t.plan(3) + + const fastify = Fastify() + fastify.register(basicAuth, { validate, utf8: false }) + + function validate (username, password, req, res, done) { + if (username === 'test' && password === '123\u00A3') { + done() + } else { + done(new Error('Unauthorized')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + /** + * @see https://datatracker.ietf.org/doc/html/rfc7617#section-2.1 + */ + authorization: 'Basic dGVzdDoxMjPCow==' + } + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 401) + t.same(JSON.parse(res.payload), { + error: 'Unauthorized', + message: 'Unauthorized', + statusCode: 401 + }) + }) +}) + test('Basic - 401', t => { t.plan(3) @@ -84,6 +169,172 @@ test('Basic - 401', t => { }) }) +test('Basic - Invalid Header value /1', t => { + t.plan(3) + + const fastify = Fastify() + fastify.register(basicAuth, { validate }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Winter is coming')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: 'Bearer ' + Buffer.from('user:pass').toString('base64') + } + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 401) + t.same(JSON.parse(res.payload), { + code: 'FST_BASIC_AUTH_MISSING_OR_BAD_AUTHORIZATION_HEADER', + error: 'Unauthorized', + message: 'Missing or bad formatted authorization header', + statusCode: 401 + }) + }) +}) + +test('Basic - Invalid Header value /2', t => { + t.plan(3) + + const fastify = Fastify() + fastify.register(basicAuth, { validate }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Winter is coming')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: 'Basic ' + Buffer.from('user').toString('base64') + } + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 401) + t.same(JSON.parse(res.payload), { + code: 'FST_BASIC_AUTH_MISSING_OR_BAD_AUTHORIZATION_HEADER', + error: 'Unauthorized', + message: 'Missing or bad formatted authorization header', + statusCode: 401 + }) + }) +}) + +test('Basic - Invalid Header value /3', t => { + t.plan(3) + + const fastify = Fastify() + fastify.register(basicAuth, { validate }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Winter is coming')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: 'Basic ' + Buffer.from('user\x00:pwd').toString('base64') + } + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 401) + t.same(JSON.parse(res.payload), { + code: 'FST_BASIC_AUTH_MISSING_OR_BAD_AUTHORIZATION_HEADER', + error: 'Unauthorized', + message: 'Missing or bad formatted authorization header', + statusCode: 401 + }) + }) +}) + +test('Basic - strictCredentials: false', t => { + t.plan(2) + + const fastify = Fastify() + fastify.register(basicAuth, { validate, strictCredentials: false }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Winter is coming')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: ' Basic ' + Buffer.from('user:pwd').toString('base64') + } + }, (err, res) => { + t.error(err) + t.equal(res.statusCode, 200) + }) +}) + test('Basic with promises', t => { t.plan(2) @@ -168,7 +419,7 @@ test('WWW-Authenticate (authenticate: true)', t => { const fastify = Fastify() const authenticate = true - fastify.register(basicAuth, { validate, authenticate }) + fastify.register(basicAuth, { validate, authenticate, utf8: false }) function validate (username, password, req, res, done) { if (username === 'user' && password === 'pwd') { @@ -211,12 +462,12 @@ test('WWW-Authenticate (authenticate: true)', t => { }) }) -test('WWW-Authenticate Realm (authenticate: {realm: "example"})', t => { +test('WWW-Authenticate Realm (authenticate: {realm: "example"}, utf8: false)', t => { t.plan(6) const fastify = Fastify() const authenticate = { realm: 'example' } - fastify.register(basicAuth, { validate, authenticate }) + fastify.register(basicAuth, { validate, authenticate, utf8: false }) function validate (username, password, req, res, done) { if (username === 'user' && password === 'pwd') { @@ -259,6 +510,54 @@ test('WWW-Authenticate Realm (authenticate: {realm: "example"})', t => { }) }) +test('WWW-Authenticate Realm (authenticate: {realm: "example"}, utf8: true)', t => { + t.plan(6) + + const fastify = Fastify() + const authenticate = { realm: 'example' } + fastify.register(basicAuth, { validate, authenticate, utf8: true }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Unauthorized')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET' + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], 'Basic realm="example", charset="UTF-8"') + t.equal(res.statusCode, 401) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: basicAuthHeader('user', 'pwd') + } + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], undefined) + t.equal(res.statusCode, 200) + }) +}) + test('Header option specified', t => { t.plan(2) @@ -633,12 +932,108 @@ test('Invalid options (authenticate)', t => { }) }) -test('Invalid options (authenticate realm)', t => { +test('authenticate: true, utf8: true', t => { + t.plan(6) + + const fastify = Fastify() + fastify + .register(basicAuth, { validate, authenticate: true, utf8: true }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Unauthorized')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET' + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], 'Basic charset="UTF-8"') + t.equal(res.statusCode, 401) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: basicAuthHeader('user', 'pwd') + } + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], undefined) + t.equal(res.statusCode, 200) + }) +}) + +test('authenticate realm: false, utf8: true', t => { t.plan(6) const fastify = Fastify() fastify - .register(basicAuth, { validate, authenticate: { realm: true } }) + .register(basicAuth, { validate, authenticate: { realm: false }, utf8: true }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Unauthorized')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET' + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], 'Basic charset="UTF-8"') + t.equal(res.statusCode, 401) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: basicAuthHeader('user', 'pwd') + } + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], undefined) + t.equal(res.statusCode, 200) + }) +}) + +test('Invalid options (authenticate realm, utf8: false)', t => { + t.plan(6) + + const fastify = Fastify() + fastify + .register(basicAuth, { validate, authenticate: { realm: true }, utf8: false }) function validate (username, password, req, res, done) { if (username === 'user' && password === 'pwd') { @@ -681,12 +1076,60 @@ test('Invalid options (authenticate realm)', t => { }) }) -test('Invalid options (authenticate realm = undefined)', t => { +test('Invalid options (authenticate realm), utf8: true', t => { t.plan(6) const fastify = Fastify() fastify - .register(basicAuth, { validate, authenticate: { realm: undefined } }) + .register(basicAuth, { validate, utf8: true, authenticate: { realm: true } }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Unauthorized')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET' + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], 'Basic charset="UTF-8"') + t.equal(res.statusCode, 401) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: basicAuthHeader('user', 'pwd') + } + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], undefined) + t.equal(res.statusCode, 200) + }) +}) + +test('Invalid options (authenticate realm = undefined, utf8: false)', t => { + t.plan(6) + + const fastify = Fastify() + fastify + .register(basicAuth, { validate, authenticate: { realm: undefined }, utf8: false }) function validate (username, password, req, res, done) { if (username === 'user' && password === 'pwd') { @@ -729,7 +1172,7 @@ test('Invalid options (authenticate realm = undefined)', t => { }) }) -test('WWW-Authenticate Realm (authenticate: {realm (req) { }})', t => { +test('WWW-Authenticate Realm (authenticate: {realm (req) { }}, utf8: false)', t => { t.plan(7) const fastify = Fastify() @@ -739,7 +1182,7 @@ test('WWW-Authenticate Realm (authenticate: {realm (req) { }})', t => { return 'root' } } - fastify.register(basicAuth, { validate, authenticate }) + fastify.register(basicAuth, { validate, authenticate, utf8: false }) function validate (username, password, req, res, done) { if (username === 'user' && password === 'pwd') { @@ -782,6 +1225,59 @@ test('WWW-Authenticate Realm (authenticate: {realm (req) { }})', t => { }) }) +test('WWW-Authenticate Realm (authenticate: {realm (req) { }}), utf8', t => { + t.plan(7) + + const fastify = Fastify() + const authenticate = { + realm (req) { + t.equal(req.url, '/') + return 'root' + } + } + fastify.register(basicAuth, { validate, authenticate, utf8: true }) + + function validate (username, password, req, res, done) { + if (username === 'user' && password === 'pwd') { + done() + } else { + done(new Error('Unauthorized')) + } + } + + fastify.after(() => { + fastify.route({ + method: 'GET', + url: '/', + preHandler: fastify.basicAuth, + handler: (req, reply) => { + reply.send({ hello: 'world' }) + } + }) + }) + + fastify.inject({ + url: '/', + method: 'GET' + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], 'Basic realm="root", charset="UTF-8"') + t.equal(res.statusCode, 401) + }) + + fastify.inject({ + url: '/', + method: 'GET', + headers: { + authorization: basicAuthHeader('user', 'pwd') + } + }, (err, res) => { + t.error(err) + t.equal(res.headers['www-authenticate'], undefined) + t.equal(res.statusCode, 200) + }) +}) + test('No 401 no realm', t => { t.plan(4) diff --git a/types/index.d.ts b/types/index.d.ts index 4724b24..8cc9b35 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -30,6 +30,8 @@ declare namespace fastifyBasicAuth { ): void | Promise; authenticate?: boolean | { realm: string | ((req: FastifyRequest) => string) }; header?: string; + strictCredentials?: boolean; + utf8?: boolean; } export const fastifyBasicAuth: FastifyBasicAuth diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 9b895f2..abae2e5 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -36,8 +36,6 @@ app.register(fastifyBasicAuth, { header: 'x-forwarded-authorization' }) - - app.register(fastifyBasicAuth, { validate: function validateCallback (username, password, req, reply, done) { expectType(username) @@ -69,6 +67,16 @@ app.register(fastifyBasicAuth, { }} }) +app.register(fastifyBasicAuth, { + validate: () => {}, + strictCredentials: true +}) + +app.register(fastifyBasicAuth, { + validate: () => {}, + utf8: true +}) + expectAssignable(app.basicAuth) expectAssignable(app.basicAuth) expectAssignable(app.basicAuth)