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

50 integrate csrf protection for login form #53

Merged
merged 6 commits into from
Mar 26, 2024
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
Binary file added httpdocs/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 10 additions & 3 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import readRouter from '@src/controller/read';
import loginRouter from '@src/controller/login';
import path from 'path';
import logger from '@src/scripts/logger';
import { baseRateLimiter } from './middleware/limit';
import { baseRateLimiter, cleanup as cleanupRateLimitedIps } from './middleware/limit';
import { cleanupCSRF } from "@src/scripts/token";

// configurations
config(); // dotenv
Expand Down Expand Up @@ -43,8 +44,8 @@ app.use(compression())
app.use(hpp());
app.use(baseRateLimiter);
app.use((req, res, next) => { // limit body for specific http methods
if(['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
return express.urlencoded({ limit: '0.5kb', extended: true })(req, res, next);
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
return express.urlencoded({ limit: '0.5kb', extended: true })(req, res, next);
}
next();
});
Expand Down Expand Up @@ -75,6 +76,12 @@ const server = app.listen(80, () => {
logger.log(`Server running //localhost:80, ENV: ${process.env.NODE_ENV}`, true);
});

// scheduled cleanup
setInterval(() => {
cleanupCSRF();
cleanupRateLimitedIps();
}, 1000 * 60 * 5);

// catching shutdowns
['SIGINT', 'SIGTERM', 'exit'].forEach((signal) => {
process.on(signal, () => {
Expand Down
20 changes: 10 additions & 10 deletions src/controller/login.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
import express, { Request, Response, NextFunction } from 'express';
import { create as createError } from '@src/middleware/error';
import logger from '@src/scripts/logger';
import { crypt, compare } from '@src/scripts/crypt';
import { loginSlowDown, loginLimiter, baseSlowDown, baseRateLimiter } from '@src/middleware/limit';
import { createToken } from '@src/scripts/token';
import { createJWT, createCSRF, validateCSRF } from '@src/scripts/token';


const router = express.Router();

router.get("/", baseSlowDown, baseRateLimiter, async function login(req: Request, res: Response) {
res.locals.text = "start";
router.get("/", baseSlowDown, baseRateLimiter, async function login(req: Request, res: Response, next: NextFunction) {
loginLimiter(req, res, () => {
const csrfToken = createCSRF(res, next);
res.locals = {...res.locals, text: 'start', csrfToken: csrfToken};
res.render("login-form");
});
});

router.post("/", loginSlowDown, async function postLogin(req: Request, res: Response, next: NextFunction) {
logger.log(req.body);
loginLimiter(req, res, async () => {
let validLogin = false;
const token = req.body.csrfToken;
const user = req.body.user;
const password = req.body.password;
let userFound = false;
if (!user || !password) {
return createError(res, 422, "Body does not contain all expected information", next);
}
if (!user || !password) { return createError(res, 422, "Body does not contain all expected information", next); }
if (!token || !validateCSRF(req.body.csrfToken)) { return createError(res, 403, "Invalid CSRF Token", next); }

// Loop through all environment variables
for (const key in process.env) {
Expand All @@ -43,13 +43,13 @@ router.post("/", loginSlowDown, async function postLogin(req: Request, res: Resp
}

if (validLogin) {
const token = createToken(req, res);
const token = createJWT(req, res);
res.json({ "token": token });
} else {
if (!userFound) {
await crypt(password); // If no matching user is found, perform a dummy password comparison to prevent timing attacks
}
return createError(res, 403, `invalid login credentials`, next);
return createError(res, 403, `Invalid credentials`, next);
}
});
});
Expand Down
37 changes: 18 additions & 19 deletions src/middleware/limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { rateLimit, Options as rateLimiterOptions } from 'express-rate-limit';
import { slowDown, Options as slowDownOptions } from 'express-slow-down';
import logger from '@src/scripts/logger';

const ipsThatReachedLimit: RateLimit.obj = {}; // prevent logs from flooding

/*
** configurations
*/
Expand Down Expand Up @@ -33,30 +35,17 @@ const baseRateLimitOptions: Partial<rateLimiterOptions> = {
}


/*
** cleanup
*/
const ipsThatReachedLimit: RateLimit.obj = {}; // prevent logs from flooding
setInterval(() => {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const ip in ipsThatReachedLimit) {
if (ipsThatReachedLimit[ip].time < oneHourAgo) {
delete ipsThatReachedLimit[ip];
}
}
}, 60 * 60 * 1000);


/*
** exported section
*/
export const baseSlowDown = slowDown(baseSlowDownOptions);

export const loginSlowDown = slowDown({
...baseSlowDownOptions,
delayAfter: 1, // no delay for amount of attempts
delayMs: (used: number) => (used - 1) * 250, // Add delay after delayAfter is reached
});
export const loginSlowDown = slowDown({
...baseSlowDownOptions,
delayAfter: 1, // no delay for amount of attempts
delayMs: (used: number) => (used - 1) * 250, // Add delay after delayAfter is reached
});

export const baseRateLimiter = rateLimit(baseRateLimitOptions);

Expand All @@ -69,4 +58,14 @@ export const loginLimiter = rateLimit({
...baseRateLimitOptions,
limit: 3,
message: 'Too many attempts without valid login',
});
});


export function cleanup() {
const oneHourAgo = Date.now() - 60 * 60 * 1000;
for (const ip in ipsThatReachedLimit) {
if (ipsThatReachedLimit[ip].time < oneHourAgo) {
delete ipsThatReachedLimit[ip];
}
}
}
4 changes: 2 additions & 2 deletions src/middleware/logged-in.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Request, Response, NextFunction } from 'express';
import { validateToken } from '@src/scripts/token';
import { validateJWT } from '@src/scripts/token';
import { create as createError } from '@src/middleware/error';


export function isLoggedIn(req: Request, res: Response, next: NextFunction) {
const result = validateToken(req);
const result = validateJWT(req);
if (!result.success) {
createError(res, result.status, result.message || "", next)
} else {
Expand Down
2 changes: 0 additions & 2 deletions src/scripts/crypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,3 @@ function pepper(password: string) {
if (!key) { throw new Error('KEYA is not defined in the environment variables'); }
return password + crypto.createHmac('sha256', key).digest("base64");
}


48 changes: 43 additions & 5 deletions src/scripts/token.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,48 @@
import jwt from 'jsonwebtoken';
import logger from '@src/scripts/logger';
import {Request, Response } from 'express';
import { NextFunction, Request, Response } from 'express';
import crypto from 'crypto';
import { create as createError } from '@src/middleware/error';


export function validateToken(req: Request) {
const csrfTokens: Set<CSRFToken> = new Set();

export function createCSRF(res: Response, next: NextFunction): string {
if (csrfTokens.size > 100) { // Max Number of Tokens in memory
res.set('Retry-After', '300'); // 5 minutes
createError(res, 503, "Too many tokens", next);
}

const token = crypto.randomBytes(16).toString('hex');
const expiry = Date.now() + (5 * 60 * 1000); // Token expires in 5 minutes
const csrfToken: CSRFToken = { token, expiry };
csrfTokens.add(csrfToken);

return token;
}

export function validateCSRF(token: string): boolean {
const currentTime = Date.now();
let valid: boolean = false;
for (const entry of csrfTokens) {
if (entry.token === token) {
valid = entry.expiry > currentTime;
csrfTokens.delete(entry);
}
}

return valid;
}

export function cleanupCSRF() {
const currentTime = Date.now();
for (const entry of csrfTokens) {
if (entry.expiry < currentTime) {
csrfTokens.delete(entry);
}
}
}

export function validateJWT(req: Request) {
const key = process.env.KEYA;
const header = req.header('Authorization');
const [type, token] = header ? header.split(' ') : "";
Expand Down Expand Up @@ -33,7 +72,7 @@ export function validateToken(req: Request) {
return { success: true };
}

export function createToken(req: Request, res: Response) {
export function createJWT(req: Request, res: Response) {
const key = process.env.KEYA;
if (!key) { throw new Error('Configuration is wrong'); }
const today = new Date();
Expand All @@ -44,6 +83,5 @@ export function createToken(req: Request, res: Response) {
};
const token = jwt.sign(payload, key, { expiresIn: 60 * 2 });
res.locals.token = token;
logger.log(JSON.stringify(payload), true);
return token;
}
21 changes: 18 additions & 3 deletions src/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,25 @@

describe('read and login', () => {
let token = "";
const testData = qs.stringify({
const testData = {
user: "TEST",
password: "test",
});
csrfToken: ""
}

it('form available / get Token', async () => {

Check warning on line 240 in src/tests/integration.test.ts

View workflow job for this annotation

GitHub Actions / eslint

Test has no assertions
let response = {data:""};
try {
response = await axios.get('http://localhost:80/login');
Dismissed Show dismissed Hide dismissed
} catch (error) {
console.error(error);
}

const regex = /name="csrfToken" value="([^"]*)"/;
const match = response.data.match(regex);
testData.csrfToken = match ? match[1] : '-';
})

test(`redirect without logged in`, async () => {
try {
await axios.get("http://localhost:80/read/");
Expand All @@ -249,7 +264,7 @@
});

it('test user can login', async () => {
const response = await axios.post('http://localhost:80/login', testData);
const response = await axios.post('http://localhost:80/login', qs.stringify(testData));
Dismissed Show dismissed Hide dismissed

expect(response.status).toBe(200);
expect(response.headers['content-type']).toEqual(expect.stringContaining('application/json'));
Expand Down
42 changes: 39 additions & 3 deletions src/tests/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,18 @@
password: "pass",
kilobyte: 'BPSwVu5vcvhWB17HcfIdyQK83mHJZKChv7zDihBJoifWK9EJFzK7VYf3kUgIqkc0io8DnSdewzc9U0GpzodQUFz0KLMaogsJruEbNSKvxnzUxS5UqSR64lLOmGumoPcn2InC0Ebpqfdiw90HFVZVlE3AY6Lhgbx8ILHi55RvpuGefDjBsePgow8Jh9sc8uVMCDglLmHQ0zk3PumMj0KlOszbMmX9fG0pPUsvLLc40biPBv9t97K3BFjYd3fGriRAQ3bFhGHBz2wzGbNQfHjKFDHuSvXOw8KReM7Wwd4Cl02QQ3RnDJVwH6cayh4BqFRXlP3i6uXw0l9qxdTv0q1CtV9rJho6zwo04gkGLvsS3AoYJQtHnOtUDdHPExu7l3nMKnPoRUwl7K2ePfHRuppFGqa43Q49bI04VjEhrB9k5S2uZJoxZdm63rIUrydmkZWdvBLVVZUIXwwIRnwLmoa26htKOz9FPKwWIPOM0NZj4jAoPhKqLDJwziNZn5UupzxBXoUM3BIyEk3K8GXs7eBduH9GCK2z2HPF0fJNtGiHASe7jCOC2mhSC5zGf9k0Yu1Ey63oQQZUtT7L57lp7UzPE2p6wzKDlbJZOn0Ho5OUfq3hE2C8fQRO1M6jDvRTiUIKhhxSHYd75Pvh4SG9lD8w5OHASusLDxmzKBUuG4GrGrQYpd0awJkqnKp5lk7psLD22YTtjTuDgI500tQLXSslxI1kIuB8RnN1LsxHyRQMVtXmNFOKKZV2U2frWpImIz2wSHCYrwRGygwDtiFfwtVwTapjhQqUMyb1vrWWi3EL1Y50fDCjDDHlvLI4N2tr2DULFf3a9m2SYWSoE6CYP4og5YyqjhqFQFm9urREInyZi9L0iQoMYxEqxTjGiVJfKmaSChSd0kQz6z2OdsxFbkMWJ2CAHOL1XNK8iFFSp93fIspaNMIonRVDCj4ZIP1LaPHDmIYcYTNU4k3Uz6VBHSIc1VjiG3sc2MZpKw9An0tJVlWbtVSk2RGYWIANAYyr5pQS'
});
const userData = qs.stringify({
const userDataWithoutToken = qs.stringify({
user: "user",
password: "pass"
});

let csrfToken = "-";
const userDataWithToken = {
user: "user",
password: "pass",
csrfToken: ""
};

describe('Login', () => {
it('form available', async () => {
let serverStatus = {};
Expand All @@ -24,6 +31,10 @@

expect(serverStatus).toBe(200);
expect(response.data).toContain('<form');
const regex = /name="csrfToken" value="([^"]*)"/;
const match = response.data.match(regex);
csrfToken = match ? match[1] : '';
expect(csrfToken.length).toBeGreaterThan(4);
})

it('server is blocking requests with large body', async () => {
Expand All @@ -39,13 +50,38 @@
}
})

it('invalid login verification test', async () => {
it('invalid csrf shows correct error', async () => {
try {
await axios.post('http://localhost:80/login', userDataWithoutToken);
Dismissed Show dismissed Hide dismissed
} catch (error) {
const axiosError = error as AxiosError;
if (axiosError.response) {
expect(axiosError.response.status).toBe(403);
if (axiosError.response.data) {
expect(JSON.stringify(axiosError.response.data)).toContain('Invalid CSRF');
} else {
throw Error("fail");
}
} else {
console.error(axiosError);
}
}
})


it('test invalid credentials to return error', async () => {
try {
await axios.post('http://localhost:80/login', userData);
userDataWithToken.csrfToken = csrfToken
await axios.post('http://localhost:80/login', qs.stringify(userDataWithToken));
Dismissed Show dismissed Hide dismissed
} catch (error) {
const axiosError = error as AxiosError;
if (axiosError.response) {
expect(axiosError.response.status).toBe(403);
if (axiosError.response.data) {
expect(JSON.stringify(axiosError.response.data)).toContain('Invalid credentials');
} else {
throw Error("fail");
}
} else {
console.error(axiosError);
}
Expand Down
5 changes: 5 additions & 0 deletions types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,11 @@ namespace Models {
}
}

interface CSRFToken {
token: string;
expiry: number;
}

interface HttpError extends Error {
status?: number;
statusCode?: number;
Expand Down
6 changes: 2 additions & 4 deletions views/login-form.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</head>

<body>
<form class="login" action="/read/login/" method="post">
<form class="login" action="/login" method="post">
<h1 class="a color-main-l2 b">Text: <%= locals.text %></h1>
<label>
User:
Expand All @@ -24,9 +24,7 @@
Submit:
<button type="submit">Submit</button>
</label>
<textarea name="text"></textarea>
<input type="hidden" name="token" value="<%= locals.token %>">
<p>Token: <%= locals.token %></p>
<input type="hidden" name="csrfToken" value="<%= locals.csrfToken %>">
</form>
</body>

Expand Down
Loading