Add CORS and move registration policies to middleware

This commit is contained in:
Sven laptop
2026-07-24 21:54:13 +02:00
parent d84b917f27
commit 9519a01ca0
8 changed files with 359 additions and 46 deletions
+51
View File
@@ -0,0 +1,51 @@
# Registration and Login Input Policy
This policy is for the website agent and describes the input rules enforced by
the API. The agent must validate inputs before submitting them and must never
silently change a users password.
## Registration
Registration accepts these required fields:
| Field | Rules |
| --- | --- |
| `name` | Required; 1100 characters after trimming. |
| `username` | Required; 150 characters after trimming; must not contain whitespace. |
| `email` | Required; 1254 characters after trimming; converted to lowercase; must not contain whitespace; must match a standard `name@domain.tld`-style format. |
| `password` | Required; 1128 characters; whitespace is preserved. |
The registration password must also:
- Be at least 12 characters long.
- Contain at least one lowercase letter, one uppercase letter, one number, and one special character.
- Not contain the username or the emails local part.
- Not be an obvious common password, including `password`, `password123`, `qwerty`, `letmein`, `welcome`, `admin`, `iloveyou`, `monkey`, `dragon`, or `abc123`.
- Not contain three identical characters in a row.
- Not contain obvious sequential patterns such as `1234`, `2345`, `6789`, or `abcd`.
- Not belong to a disposable or temporary email address.
## Login
Login accepts only `email` and `password`:
- Both fields are required and may contain 1254 characters for email and 1128 characters for password.
- Email is trimmed, converted to lowercase, and must not contain whitespace.
- Email must pass the same `name@domain.tld`-style format check used during registration.
- Password whitespace is preserved; the password must be submitted exactly as entered during registration.
- Registration-only password complexity checks and disposable-email checks are not repeated during login.
The agent must not trim, lowercase, or otherwise transform the password. The
API removes control characters from input values; ordinary password whitespace
is intentionally retained.
## Invalid input and authentication failures
- Do not submit a form until all applicable client-side rules pass.
- If the API returns HTTP `400`, show the validation message and let the user correct the input.
- If login returns HTTP `401`, show a generic “Invalid email or password” message. Do not reveal whether the email exists.
- Never log or expose passwords, password hashes, salts, or authentication tokens.
- Unexpected server failures must be shown as a generic failure message and retried only when appropriate; do not expose database or stack-trace details.
The API remains the authoritative validator. Client-side validation improves the
user experience but must not replace server-side validation.
+13 -2
View File
@@ -1,7 +1,16 @@
const express = require('express');
const cors = require('cors');
const app = express();
const dotenv = require('dotenv');
require('dotenv').config();
const allowedOrigins = (process.env.CORS_ALLOWED_ORIGINS || '')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
// Apply configured CORS origins to every endpoint, including preflight requests.
app.use(cors({ origin: allowedOrigins }));
app.use(express.json());
// Data routes
@@ -14,9 +23,11 @@ app.use('/post_data', postDataRoute);
app.use('/delete_data', deleteDataRoute);
// People routes
const CreatePersonRoute = require('./features/people/CREATE_people');
const CreatePersonRoute = require('./features/people/REGISTER_people');
const LoginPersonRoute = require('./features/people/LOGIN_people');
app.use('/create_person', CreatePersonRoute);
app.use('/auth/register', CreatePersonRoute);
app.use('/auth/login', LoginPersonRoute);
// Relation routes
const CreateRelationRoute = require('./features/relations/CREATE_friendship');
-43
View File
@@ -1,43 +0,0 @@
const express = require('express');
const router = express.Router();
var driver = require('../../graph_db');
const pool = require('../../db');
async function create_people(req, res) {
const { name, age, gender } = req.body;
try {
const { records } = await driver.executeQuery('Create (p:Person {name: $name, age: $age, gender: $gender})', { name: name, age: age, gender: gender }, { database: process.env.GRAPH_DB_NAME});
}
catch (err) {
console.error('database query failed', {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send('Data was not pushed to graph database, request failed');
}
try {
const { rows } = await pool.query('INSERT INTO people (name, age, gender) VALUES ($1, $2, $3)', [name, age, gender]);
console.log(rows[0]);
res.status(200).send('Data has been pushed to both databases');
res.json(rows[0]);
}
catch (err) {
console.error('database query failed', {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
res.status(500).send('Data was not pushed to database, request failed');
}
};
router.post('/', create_people);
module.exports = router;
+44
View File
@@ -0,0 +1,44 @@
const express = require('express');
const router = express.Router();
const pool = require('../../db');
const crypto = require('crypto');
const { promisify } = require('util');
const { sanitizeLoginInput } = require('../../middleware/sanitize_person_input');
const scrypt = promisify(crypto.scrypt);
async function login(req, res) {
try {
const { email, password } = req.loginInput;
const { rows } = await pool.query(
'SELECT id, password FROM people WHERE email = $1',
[email]
);
if (rows.length === 0) {
res.status(401).send('Invalid email or password');
return;
}
const [salt, storedKeyHex] = rows[0].password.split(':');
const storedKey = Buffer.from(storedKeyHex || '', 'hex');
const derivedKey = await scrypt(password, salt, 64);
if (
storedKey.length !== derivedKey.length ||
!crypto.timingSafeEqual(storedKey, derivedKey)
) {
res.status(401).send('Invalid email or password');
return;
}
res.status(200).json({ id: rows[0].id });
}
catch (err) {
console.error('login failed', { message: err.message, code: err.code });
res.status(500).send('Login failed, request could not be completed');
}
};
router.post('/', sanitizeLoginInput, login);
module.exports = router;
+88
View File
@@ -0,0 +1,88 @@
const express = require('express');
const router = express.Router();
var driver = require('../../graph_db');
const pool = require('../../db');
var crypto = require('crypto');
const { promisify } = require('util');
const { sanitizePersonInput } = require('../../middleware/sanitize_person_input');
const scrypt = promisify(crypto.scrypt);
async function insert_db( name, username, email, password ) {
const salt = crypto.randomBytes(16).toString('hex');
const derivedKey = await scrypt(password, salt, 64);
const hashed_password = `${salt}:${derivedKey.toString('hex')}`;
try {
const { rows } = await pool.query(
'INSERT INTO people (name, username, email, password) VALUES ($1, $2, $3, $4) RETURNING id',
[name, username, email, hashed_password]
);
return rows[0].id;
}
catch (err) {
console.error('database query failed', {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
throw err;
}
};
async function insert_graph_db( name, username, db_id ) {
try {
await driver.executeQuery(
'CREATE (p:Person {db_id: $db_id, name: $name, username: $username})',
{ db_id, name, username },
{ database: process.env.GRAPH_DB_NAME }
);
}
catch (err) {
console.error('database query failed', {
message: err.message,
code: err.code,
detail: err.detail,
hint: err.hint,
stack: err.stack,
});
throw err;
}
};
async function create_people(req, res) {
const person = req.person;
let db_id;
try {
db_id = await insert_db(
person.name,
person.username,
person.email,
person.password
);
}
catch (err) {
res.status(500).send('Data was not pushed to database, request failed');
return;
}
try {
await insert_graph_db(person.name, person.username, db_id);
res.status(201).json({ id: db_id });
}
catch (err) {
res.status(500).send('Data was not pushed to graph database, request failed');
}
};
router.post('/', sanitizePersonInput, create_people);
module.exports = router;
+123
View File
@@ -0,0 +1,123 @@
const { isDisposableEmail } = require('fakeout');
const LIMITS = {
name: 100,
username: 50,
email: 254,
password: 128,
};
const MIN_PASSWORD_LENGTH = 12;
const COMMON_PASSWORDS = new Set([
'123456789', 'password', 'password123', 'qwerty',
'qwertyuiop', 'letmein', 'welcome', 'admin',
'iloveyou', 'monkey', 'dragon', 'abc123',
]);
function cleanText(value, { trim = true } = {}) {
if (typeof value !== 'string') {
return '';
}
const cleaned = value.replace(/[\u0000-\u001F\u007F]/g, '');
return trim ? cleaned.trim() : cleaned;
}
function cleanPersonInput(input) {
return {
name: cleanText(input.name),
username: cleanText(input.username),
email: cleanText(input.email).toLowerCase(),
// Password whitespace is preserved, but control characters are removed.
password: cleanText(input.password, { trim: false }),
};
}
function validateCommonFields(values, { requireName = true } = {}) {
const fields = requireName
? ['name', 'username', 'email', 'password']
: ['email', 'password'];
for (const field of fields) {
if (!values[field] || values[field].length > LIMITS[field]) {
throw new Error(`${field} must be between 1 and ${LIMITS[field]} characters`);
}
}
if (/\s/.test(values.username)) {
throw new Error('username must not contain spaces');
}
if (/\s/.test(values.email)) {
throw new Error('email must not contain spaces');
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email)) {
throw new Error('email must be a valid email address');
}
}
function validateRegistrationInput(values) {
validateCommonFields(values);
if (values.password.length < MIN_PASSWORD_LENGTH) {
throw new Error(`password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
const passwordLower = values.password.toLowerCase();
const normalizedPassword = passwordLower.replace(/[^a-z0-9]/g, '');
const emailLocalPart = values.email.split('@')[0];
if (
COMMON_PASSWORDS.has(normalizedPassword) ||
passwordLower.includes(values.username.toLowerCase()) ||
passwordLower.includes(emailLocalPart)
) {
throw new Error('password is too common or contains account details');
}
if (!/[a-z]/.test(values.password) || !/[A-Z]/.test(values.password)) {
throw new Error('password must contain lowercase and uppercase letters');
}
if (!/[0-9]/.test(values.password)) {
throw new Error('password must contain a number');
}
if (!/[^a-zA-Z0-9\s]/.test(values.password)) {
throw new Error('password must contain a special character');
}
if (/(.)\1\1/.test(values.password) || /(0123|1234|2345|3456|4567|5678|6789|abcd|bcde|cdef)/i.test(values.password)) {
throw new Error('password must not contain obvious repeated or sequential characters');
}
if (isDisposableEmail(values.email)) {
throw new Error('temporary email addresses are not allowed');
}
}
function sanitizePersonInput(req, res, next) {
try {
const values = cleanPersonInput(req.body || {});
validateRegistrationInput(values);
req.person = values;
next();
}
catch (err) {
res.status(400).send(err.message);
}
}
function sanitizeLoginInput(req, res, next) {
try {
const values = cleanPersonInput(req.body || {});
validateCommonFields(values, { requireName: false });
req.loginInput = { email: values.email, password: values.password };
next();
}
catch (err) {
res.status(400).send(err.message);
}
}
module.exports = { sanitizePersonInput, sanitizeLoginInput };
+37
View File
@@ -9,8 +9,10 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"fakeout": "^1.0.65",
"neo4j-driver": "^6.2.0",
"pg": "^8.22.0",
"router": "^2.2.0"
@@ -279,6 +281,23 @@
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -434,6 +453,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/fakeout": {
"version": "1.0.65",
"resolved": "https://registry.npmjs.org/fakeout/-/fakeout-1.0.65.tgz",
"integrity": "sha512-VaHtvOaBuSpetcCU243ex4bMII2SLiTq9FS6NyD23k0doFRIM0v3+a8FtrMlQf9s8XsXkMUClt6Oc5N9V73rSQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -892,6 +920,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+2
View File
@@ -12,8 +12,10 @@
"license": "ISC",
"type": "commonjs",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"fakeout": "^1.0.65",
"neo4j-driver": "^6.2.0",
"pg": "^8.22.0",
"router": "^2.2.0"