mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-10 12:28:28 +00:00
Security - Check advanced_config permissions before creating a certificate on host update (proxy, redirection and 404 hosts). A refused request previously requested a Let's Encrypt certificate first, leaving an orphan certificate and using up rate limit. - Only trust X-NPM-Peer-Addr when it comes with a secret generated at container start. The admin nginx sends both headers. Any proxy host or stream forwarding to 127.0.0.1:3000 was also a loopback peer, so it could forge the header and get a fresh rate-limit bucket on every request. Without the secret (dev, or a mounted production.conf), the header is ignored. - Add a login limit per IP across all accounts (30 failed attempts a minute) alongside the per-account one. Before this, one address could spray passwords over any number of accounts. Behind a shared proxy, everyone using that proxy shares this limit. - Stop trusting X-Real-IP from loopback. It was only there for an older version of the rate limiter, and it let a stream host forwarding to a local proxy host forge the client IP and get past IP access lists. Loopback now behaves as it does on master. Correctness - Refuse to delete a certificate that hosts still use (400, with counts per host type). Their configs include its files, and a successful revoke deleted them anyway, which broke nginx -t. Otherwise the certificate is revoked and its files are removed even if the revoke fails. - Let non-admins clear a location's advanced config. Deleting the location was already allowed and has the same effect, and a location deleted and re-added at the same path arrives with an empty config, which was refused. - Clear proxy host grants (meta.proxy_host_ids) when visibility isn't "user" or proxy hosts are hidden, and drop ids of deleted hosts. Before, hidden grants came back when proxy hosts were shown again. The backend enforces this; the permissions modal also stops sending them. - 404 host and redirection host modals now wait for the current user to load, so an admin's advanced config change isn't dropped if they submit before it loads. Tests - NonAdminHosts.cy.js: refused change doesn't create a certificate, location rename and clear rules, grant clearing. - Certificates.cy.js: deleting a certificate that is in use.
125 lines
3.9 KiB
JavaScript
125 lines
3.9 KiB
JavaScript
import express from "express";
|
|
import internalToken from "../internal/token.js";
|
|
import jwtdecode from "../lib/express/jwt-decode.js";
|
|
import createRateLimiter from "../lib/express/rate-limit.js";
|
|
import apiValidator from "../lib/validator/api.js";
|
|
import { debug, express as logger } from "../logger.js";
|
|
import { getValidationSchema } from "../schema/index.js";
|
|
|
|
// Two limits apply to logins. Per IP + target account: when the admin UI sits behind another
|
|
// proxy every client can resolve to the same IP, and an IP-only bucket at this size would let
|
|
// anyone lock out every user with a handful of bad logins.
|
|
const tokenRateLimiter = createRateLimiter({
|
|
windowMs: 60 * 1000,
|
|
max: 10,
|
|
message: "Too many login attempts. Please try again in 1 minute.",
|
|
skipSuccessfulRequests: true,
|
|
keyGenerator: (req, clientIp) => {
|
|
const identity = typeof req.body?.identity === "string" ? req.body.identity.trim().toLowerCase() : "";
|
|
return `${clientIp}|${identity}`;
|
|
},
|
|
});
|
|
|
|
// And per IP across all accounts, so one address can't spray passwords over many accounts at
|
|
// 10 a minute each. Only failures count. Behind a shared proxy this cap is shared by everyone
|
|
// using it, which is why it's higher than the per-account one.
|
|
const tokenIpRateLimiter = createRateLimiter({
|
|
windowMs: 60 * 1000,
|
|
max: 30,
|
|
message: "Too many login attempts. Please try again in 1 minute.",
|
|
skipSuccessfulRequests: true,
|
|
});
|
|
|
|
// Keyed on the user the challenge belongs to, not the challenge token itself, so fetching
|
|
// fresh challenge tokens doesn't reset the code-guessing budget. The payload is read without
|
|
// verifying the signature; verify2FA does that. A forged token only affects buckets for the
|
|
// sender's own IP.
|
|
const challengeUserId = (token) => {
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString());
|
|
return String(payload?.attrs?.id ?? "");
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const twoFactorRateLimiter = createRateLimiter({
|
|
windowMs: 60 * 1000,
|
|
max: 10,
|
|
message: "Too many login attempts. Please try again in 1 minute.",
|
|
skipSuccessfulRequests: true,
|
|
keyGenerator: (req, clientIp) => `${clientIp}|${challengeUserId(req.body?.challenge_token)}`,
|
|
});
|
|
|
|
const router = express.Router({
|
|
caseSensitive: true,
|
|
strict: true,
|
|
mergeParams: true,
|
|
});
|
|
|
|
router
|
|
.route("/")
|
|
.options((_, res) => {
|
|
res.sendStatus(204);
|
|
})
|
|
|
|
/**
|
|
* GET /tokens
|
|
*
|
|
* Get a new Token, given they already have a token they want to refresh
|
|
* We also piggy back on to this method, allowing admins to get tokens
|
|
* for services like Job board and Worker.
|
|
*/
|
|
.get(jwtdecode(), async (req, res, next) => {
|
|
try {
|
|
const data = await internalToken.getFreshToken(res.locals.access, {
|
|
expiry: typeof req.query.expiry !== "undefined" ? req.query.expiry : null,
|
|
scope: typeof req.query.scope !== "undefined" ? req.query.scope : null,
|
|
});
|
|
res.status(200).send(data);
|
|
} catch (err) {
|
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
|
next(err);
|
|
}
|
|
})
|
|
|
|
/**
|
|
* POST /tokens
|
|
*
|
|
* Create a new Token
|
|
*/
|
|
.post(tokenIpRateLimiter, tokenRateLimiter, async (req, res, next) => {
|
|
try {
|
|
const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body);
|
|
const result = await internalToken.getTokenFromEmail(data);
|
|
res.status(200).send(result);
|
|
} catch (err) {
|
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router
|
|
.route("/2fa")
|
|
.options((_, res) => {
|
|
res.sendStatus(204);
|
|
})
|
|
|
|
/**
|
|
* POST /tokens/2fa
|
|
*
|
|
* Verify 2FA code and get full token
|
|
*/
|
|
.post(twoFactorRateLimiter, async (req, res, next) => {
|
|
try {
|
|
const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body);
|
|
const result = await internalToken.verify2FA(challenge_token, code);
|
|
res.status(200).send(result);
|
|
} catch (err) {
|
|
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export default router;
|