- HTTPS (i.e. Apache2)
- Apache2
- Nginx
- HTTP Headers
- Authorization
- Database
- PHP
- Node.js/npm
- Express
- User registration
- User login
- CSRF Token
- Cookies
- File upload
- Docker
- Ubuntu VPS
- HTML DOM sanitization
- Analysis tools
- Sources and resources
This document is a concise guide that aims to list the main web vulnerabilities, particularly JavaScript, and some solutions. This guide should be supplemented with quality, up-to-date documentation.
This guide is intended for full-stack developers working with JavaScript technologies (React, Vue, etc.) and a Node.js/PHP backend with CRUD/REST APIs.
.NET, JAVA, Django or Ruby are therefore not included in this guide.
Install certificates with Let's Encrypt:
sudo apt install certbot python3-certbot-apachesudo certbot certonly --standalone -d example.com -d www.example.comAdd a CAA record to your DNS zone:
CAA 0 issue "letsencrypt.org"Write in /etc/apache2/apache2.conf:
Redirect permanent / <https://domain.com/>General purpose web applications should default to TLS 1.3 (support TLS 1.2 if necessary) with all other protocols disabled. Only enable TLS 1.2 and 1.3. Go to /etc/apache2/conf-available/ssl.conf and write:
SSLProtocol TLSv1.2 TLSv1.3HTTP Strict Transport Security (HSTS) is a mechanism for websites to instruct web browsers that the site should only be accessed over HTTPS. This mechanism works by sites sending a Strict-Transport-Security HTTP response header containing the site's policy. Write in /etc/apache2/apache2.conf:
Header set strict-transport-security "max-age=31536000; includesubdomains; preload"Reload Apache and submit your website to https://hstspreload.org/
Important
Only enable preload if you're 100% HTTPS everywhere (including subdomains)
HTTP/2 provides a solution to several problems that the creators of HTTP/1.1 had not anticipated. In particular, HTTP/2 is much faster and more efficient than HTTP/1.1:
sudo a2enmod http2
Mod security is a free Web Application Firewall (WAF) that works with Apache2 or nginx:
sudo apt install libapache2-modsecurity
SecRuleEngine On <- /etc/modsecurity/modsecurity.confa2enmod deflate
Revealing web server signature with server/PHP version info can be a security risk as you are essentially telling attackers known vulnerabilities of your system. Write in /etc/apache2/apache2.conf:
ServerTokens Prod
ServerSignature OffWrite in /etc/apache2/apache2.conf:
<Directory />
Options FollowSymLinks
AllowOverride None
Require all denied
</Directory>
<Directory /var/www>
Options -Indexes
AllowOverride None
Require all granted
</Directory>A nginx template following the same Apache2 config above:
server {
listen 80;
server_name your-website.com www.your-website.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name your-website.com www.your-website.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
limit_except GET POST {
deny all;
}
}
}Recommended headers for Apache2 and nginx:
x-content-type-options: "nosniff"
access-control-allow-origin "https://domain.com"
referrer-policy "no-referrer"
content-security-policy "upgrade-insecure-requests; default-src 'self'; base-uri 'none'; connect-src 'self'; font-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self'; media-src 'self'; object-src 'none' ; script-src 'self'; script-src-attr 'none'; style-src 'self'"
permissions-policy "geolocation=(), …"
cross-origin-embedder-policy: "require-corp"
cross-origin-opener-policy "same-origin"
cross-origin-resource-policy "cross-origin"In addition be sure to remove Server and X-Powered-By headers.
Note
Never use X-XSS-Protection, it is depracated and can create XSS vulnerabilities in otherwise safe websites. X-Frame-Options is replaced by frame-ancestors 'none'. Avoid as much as possible unsafe-inline and unsafe-eval. Use hashes or nonces for inline scripts/styles.
- Deny by default
- Enforce least privileges
- Validate all permissions
- Validate files access
- Sanitize files upload
- Require user password for sensitive actions
- Use a strong database password and restrict user permissions
- Hash all user login passwords before storing them in the database
- For MySQL/MariaDB databases, use prepared queries to prevent injections
# php
$query = $PDO->prepare("SELECT a FROM b WHERE c=:c LIMIT 1");
$query->execute([':c' => $c]);
$row = $query->fetch();// js
try {
const [rows] = await pool.execute(
"SELECT a FROM b WHERE c = ? LIMIT 1",
[userId]
)
if (rows.length !== 1) return 0
return rows[0].a
} catch {
return 0
}- For MySQL/MariaDB databases, use mysql_secure_installation
- For NoSQL databases, like MongoDB, use a typed model to prevent some injections
- Avoid $accumulator, $function, $where in MongoDB
- Store keys in a secure vault like AWS Secrets Manager, Azure KeyVault or Hashicorp
PHP-FPM (FastCGI Process Manager) is often preferred over Apache mod_php due to its superior performance, process isolation, and flexible configuration:
sudo apt install php<version>-fpm
sudo a2dismod mpm_prefork
sudo a2enmod mpm_event proxy_fcgi proxyPDO (PHP Data Objects) is a Database Access Abstraction Layer that provides a unified interface for accessing various databases. A secure MySQL database connection with PDO:
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
$dsn = "mysql:host=$host;dbname=$db";
try {
$PDO = new PDO($dsn, $user, $pass, $options);
} catch (Exception $e) {
throw new Exception('Connection failed');
return;
}A hardened template for PHP-FPM, write in /etc/php/<version>/fpm/php.ini:
expose_php = off
error_reporting = e_all & ~e_deprecated & ~e_strict
display_errors = off
display_startup_errors = off
ignore_repeated_errors = off
allow_url_fopen = off
allow_url_include = off
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = strict
session.sid_length = > 128- Always keep all npm dependencies up to date, use npm audit
- Limit the use of dependencies
- Use npm doctor to ensure that your npm installation has what it needs to manage your JavaScript packages
- Use eslint to write quality code
[RECOMMENDED] Secure Express server (behind reverse proxy):
import express from 'express'
import helmet from 'helmet'
const app = express()
// If behind a reverse proxy like Nginx
app.set('trust proxy', 1)
// If requests are application/json
app.use(express.json({ limit: '50kb' }))
// Security headers
app.use(helmet())
app.disable('x-powered-by')
const PORT = process.env.PORT || 3000
app.listen(
PORT,
'127.0.0.1', // bind only to localhost if possible
() => {
console.log(`Server is running on port ${PORT}`)
})Or, secure Express server with HTTPS (WITHOUT reverse proxy):
import express from 'express'
import helmet from 'helmet'
import https from 'https'
import fs from 'fs'
const app = express()
app.use(express.json({ limit: '50kb' }))
app.use(helmet())
app.disable('x-powered-by')
const PORT = process.env.PORT || 3000
https.createServer({
key: fs.readFileSync(process.env.SSL_KEY),
cert: fs.readFileSync(process.env.SSL_CERT),
}, app).listen(PORT, () => {
console.log(`HTTPS server running on port ${PORT}`)
})If you want CORS requests:
import cors from 'cors'
const corsOptions = {
origin: process.env.YOUR_URL,
credentials: true,
}
app.use(cors(corsOptions))Secure all routes with express-rate-limit:
const router = express.Router()
const limiter = rateLimit({
windowMs: 5 * 60 * 1000,
max: 100,
message: 'Too many requests, please try again later.',
})
router.use(limiter)If you are using express-session, secure it and store in Redis:
import express from 'express'
import session from 'express-session'
import { RedisStore } from 'connect-redis'
import { createClient } from 'redis'
const app = express()
const PORT = process.env.PORT || 3000
const redisClient = createClient({
url: process.env.REDIS_URL,
})
try {
await redisClient.connect()
console.log('Redis client connected')
} catch (err) {
console.error(err)
process.exit(1)
}
const redisStore = new RedisStore({
client: redisClient,
prefix: 'your_app:',
ttl: 3600,
})
app.use(
session({
store: redisStore,
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: 'Strict',
},
})
)A secure Express user registration using bcrypt and zod:
const createAccountSchema = z.object({
nameCreate: z
.string()
.min(3)
.max(30)
.regex(/^[\p{L} -]+$/u),
psswdCreate: z.string().min(10).max(64)
})
router.post('/create-account', async (req, res) => {
const parsed = createAccountSchema.safeParse(req.body)
if (!parsed.success) {
return res.status(400).send('Account creation failed')
}
const { nameCreate, psswdCreate } = parsed.data
const id = crypto.randomBytes(12).toString('hex')
const psswdCreateHash = await bcrypt.hash(psswdCreate, 12)
try {
await pool.execute(
"INSERT INTO users (id, name, psswd) VALUES (?, ?, ?)",
[id, nameCreate, psswdCreateHash]
)
return res.status(200).send('Account created successfully')
} catch {
return res.status(400).send('Account creation failed')
}
})A secure Express login system using rate limiting and passport.js:
import rateLimit from 'express-rate-limit'
const loginLimiter = rateLimit({
windowMs: 3 * 60 * 1000,
max: 5,
message: 'Too many login attempts from this IP, please try again later'
})
app.post('/login', loginLimiter, async (req, res, next) => {
try {
const user = await new Promise((resolve, reject) => {
passport.authenticate('local', { session: false }, (err, user) => {
if (err) return reject(err)
resolve(user)
})(req, res, next)
})
if (!user) return res.status(401).send('Wrong username or password.')
req.session.regenerate((err) => {
if (err) return res.status(401).send('Wrong username or password.')
// create session here
})
return res.status(200).send('Logged in!')
} catch {
return res.status(401).send('Wrong username or password.')
}
})Use MFA (2FA) for sensible apps !
SameSite cookie is good as defense in depth, but doesn't prevent all possible CSRF attacks. Use CSRF Token or Double Submit Cookie. Read OWASP
I recommend csrf-csrf for Express.
Domain=domain.com; Path=/; Secure; HttpOnly; SameSite=Lax or Strict
Secure: All cookies must be set with the Secure directive, indicating that they should only be sent over HTTPS
HttpOnly: Cookies that don't require access from JavaScript should have the HttpOnly directive set to block access
Domain: Cookies should only have a Domain set if they need to be accessible on other domains; this should be set to the most restrictive domain possible
Path: Cookies should be set to the most restrictive Path possible
SameSite:
- Strict (preferred): Only send the cookie in same-site contexts. Cookies are omitted in cross-site requests and cross-site navigation
- Lax: Send the cookie in same-site requests and when navigating to your website. Use this value if Strict is too restrictive
Important
Since using SameSite with the Strict attribute is relatively safe, it is also recommended to use a CSRF token for sensible apps
Always the extension AND the actual MIME type AND size before saving it:
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import { fileTypeFromBuffer } from 'file-type'
const uploadDir = '/var/uploads'
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
const ALLOWED_TYPES = [
{
mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
ext: 'xlsx'
}
]
const storage = multer.memoryStorage()
export const upload = multer({
storage,
limits: { fileSize: 512000 }
})
export const handleUpload = async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' })
}
const buffer = req.file.buffer
const fileType = await fileTypeFromBuffer(buffer)
if (!fileType) {
return res.status(400).json({ error: 'Unknown file type' })
}
const allowed = ALLOWED_TYPES.find(
t => t.mime === fileType.mime && t.ext === fileType.ext
)
if (!allowed) {
return res.status(400).json({ error: 'Invalid file type' })
}
const originalName = req.file.originalname
const baseName = path.parse(originalName).name
const safeName = baseName.replace(/[^a-z0-9_\-]/gi, '_')
const finalName = `${safeName}.${fileType.ext}`
const finalPath = path.join(uploadDir, finalName)
await fs.promises.writeFile(finalPath, buffer)
next()
} catch (err) {
next(err)
}
}- Use official and minimal images
- Use .dockerignore to hide server secrets
- Run containers with a read-only filesystem using --read-only flag
- Avoid the use of ADD in favor of COPY
- Set a user with restricted permissions in DockerFile
RUN groupadd -r myuser && useradd -r -g myuser myuser
# HERE DO WHAT YOU HAVE TO DO AS A ROOT USER LIKE INSTALLING PACKAGES ETC.
USER myuser- Use a strong passwords for all users
- Disable root login
- Create a user with restricted permissions and 2FA or physical key
- Always update all packages and limit their number
- Disable unused network ports
- Use Fail2Ban to prevent DoS and Bruteforce attacks, disable SSH root login in sshd_config
PasswordAuthentication no
PubkeyAuthentication yes
PermitRootLogin no- Always make secure and regular backups
- Log everything
- Use SFTP instead of FTP
- Use a firewall like iptables or ufw
- Use robots.txt to disallow all by default and don't disclose sensitive URLs
Zod is a great tool to sanitize inputs:
import * as z from 'zod'
const User = z.object({
name: z.string().min(3).max(64),
})Always use rel="noreferrer noopener" to prevent the referrer header from being sent to the new page.
Never trust user inputs, validate and sanitize all data. Use GET for idempotent requests, POST for state changes. Sanitize and valid data with a strong regex and use application/json instaed of application/x-www-form-urlencoded:
try {
if (!yourData ||
// regex, sanitize, etc.
) return
const data = JSON.stringify({ yourData })
const res = await fetch('api/fetch/', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: data,
})
if (!res.ok) {
//
return
}
//
} catch {
//
}Never use innerHTML without DOMPurify; use innerText or textContent instead. You can also create your element with document.createElement().
Never use these JavaScript function. Executing JavaScript from a string is an enormous security risk. It is far too easy for a bad actor to run arbitrary code when you use eval().
DOMPurify sanitizes HTML and prevents XSS attacks. You can feed DOMPurify with string full of dirty HTML and it will return a string (unless configured otherwise) with clean HTML. DOMPurify will strip out everything that contains dangerous HTML and thereby prevent XSS attacks and other nastiness.
import DOMPurify from 'dompurify'
const PURIFY_CONFIG = {
SANITIZE_NAMED_PROPS: true,
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(https?|mailto|tel):/i
}
const clean = DOMPurify.sanitize(dirty, PURIFY_CONFIG)https://developer.mozilla.org/en-US/
https://www.cnil.fr/fr/securiser-vos-sites-web-vos-applications-et-vos-serveurs
https://cyber.gouv.fr/publications/recommandations-de-securite-relatives-tls
https://owasp.org/www-project-top-ten/
https://cheatsheetseries.owasp.org/
https://expressjs.com/en/advanced/best-practice-security.html