Files
Daniel Hiller de4d768831 fix: close the gaps a security review found in the default install
Reported against 2.2.1 by someone reading the source. Every finding held.

**Sign-in had no ceiling.** Failures were logged with the caller's address
and nothing more. Two counters now — one per address, one per account —
because the two attacks look different: one source working through many
accounts is caught by the first, many sources working on one account by the
second, and behind a proxy only the second still means anything.

The count is kept in the process that serves the request. PLANKA needs no
Redis and the stock deployment is one container; run several and each keeps
its own count, which multiplies the ceiling by their number. That trade is
written where the limits are configured.

**The second factor could be guessed at leisure.** Six digits, and a
pending token that stayed valid for its full ten minutes however many codes
were wrong. Wrong codes are now counted on the session row — in the
database, so the count survives a restart and holds across every process —
and when the budget is spent the session is destroyed. After that even the
right code is refused and the login starts over from the password.

**Avatars, background images and favicons** checked the token's signature
and nothing else, so a revoked session, a deactivated account or a changed
password all kept working there for as long as the signature lasted, which
is a year by default. The five checks the API makes now live in one helper
that both use, rather than the shortened copy that had drifted from it.

**A link attachment's favicon** was fetched from wherever the URL pointed.
Storing a link is harmless — it is a string the user typed — but fetching
its icon is a request the server makes to an address the user chose, and
whether an icon came back reported on what is reachable from inside the
network. Server-side fetches now refuse private, loopback and link-local
addresses, `169.254.169.254` among them. The attachment is still created:
linking to an internal wiki is a legitimate thing to do, and it was the
server's own request that had to stop.

**The signing key.** Our own compose file ships `notsecretkey`, and it is
printed in the documentation — so on any instance that copied it, anyone can
sign a token for any account. PLANKA now says so on every start, and keeps
saying it, along with a key that is missing or shorter than 32 characters.
The placeholder carries the warning inline, where it is copied from.

**The backup script** wrote password hashes, live sessions, TOTP secrets and
SMTP credentials to an unencrypted archive. `BACKUP_PASSPHRASE` now encrypts
it, and without one the script says what it just put on disk. It also says
what it is — an example for the stock compose stack, not a backup concept —
and names the window between the database dump and the file copy, which no
ordering closes.
2026-08-28 20:55:54 +02:00

60 lines
2.0 KiB
JavaScript

/**
* Seed Function
* (sails.config.bootstrap)
*
* A function that runs just before your Sails app gets lifted.
*/
// The value our own example compose file ships. Anyone who copied that file
// and never read the paragraph next to it is running with a signing key that
// is published in our documentation — which means anyone can mint a token for
// any account on their instance.
const EXAMPLE_SECRET_KEY = 'notsecretkey';
// What the documentation asks for, and what `openssl rand -hex 32` produces.
const MIN_SECRET_KEY_LENGTH = 32;
const RULE = '─'.repeat(72);
// Loud on purpose. This is not a misconfiguration that degrades a feature; it
// is the difference between sessions that can be forged and sessions that
// cannot, and it is invisible from inside a working instance.
const warn = (headline, detail) => {
sails.log.warn(RULE);
sails.log.warn(`SECURITY: ${headline}`);
detail.forEach((line) => sails.log.warn(` ${line}`));
sails.log.warn(RULE);
};
module.exports.bootstrap = async () => {
const secretKey = sails.config.session.secret;
if (!secretKey) {
warn('SECRET_KEY is not set.', [
'Every access token is signed with it. Without one, sessions cannot be',
'trusted. Generate a key with: openssl rand -hex 32',
]);
return;
}
if (secretKey === EXAMPLE_SECRET_KEY) {
warn('SECRET_KEY is still the value from the example configuration.', [
'It is published in our documentation, so anyone can sign a token for any',
'account on this instance. Replace it now: openssl rand -hex 32',
'',
'Changing it invalidates every token already issued — everyone signs in',
'again once, and that is the whole cost.',
]);
return;
}
if (secretKey.length < MIN_SECRET_KEY_LENGTH) {
warn(`SECRET_KEY is shorter than ${MIN_SECRET_KEY_LENGTH} characters.`, [
`It is ${secretKey.length}. A short key is a guessable key, and guessing it`,
'means forging sessions. Generate a proper one: openssl rand -hex 32',
]);
}
};