Harden four things the audit turned up
**The login rate limiter could be sidestepped.** X-Forwarded-For is a list each hop appends to, and nginx's $proxy_add_x_forwarded_for appends ours — so a client sending "X-Forwarded-For: 1.2.3.4" arrives as "1.2.3.4, <their real address>". Reading the leftmost entry, as we did, handed the caller a rate-limit key they could change per request: unlimited password guessing against a deployment that looks correctly configured. Read from the right instead, skip hops that are themselves trusted proxies, and believe the header only when the peer is one (loopback and the private ranges by default, TRUSTED_PROXIES to be explicit). **The upload cap was a suggestion.** It read content-length, which a chunked request simply omits. Count the bytes through a stream, as the image proxy already does. **App password secrets were drawn with a modulo.** 256 is not a multiple of 33, so the first 25 characters of the alphabet came up on 8 byte values and the last 8 on only 7. Rejection sampling instead. The test weighs the whole tail of the alphabet rather than single characters, because a 7/8 skew is invisible per character against the noise — and it does fail when the bias is put back. **Upstream headers were relayed wholesale.** Anything the mail server set — cookies, auth challenges, CORS grants — landed on our origin, where it means something else. Allowlist what is actually wanted.
This commit is contained in:
+17
-8
@@ -371,16 +371,25 @@ async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string
|
||||
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
|
||||
}
|
||||
|
||||
/** A legacy app password a person can read off a screen and type. */
|
||||
function readableSecret(): string {
|
||||
/**
|
||||
* A legacy app password a person can read off a screen and type.
|
||||
*
|
||||
* Drawn by rejection sampling. Plain `% alphabet.length` would favour the
|
||||
* first 25 characters, because 256 is not a multiple of 33: each of those
|
||||
* would come up on 8 byte values and the remaining 8 on only 7.
|
||||
*/
|
||||
export function readableSecret(): string {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
|
||||
const bytes = randomBytes(20);
|
||||
let out = "";
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (i > 0 && i % 5 === 0) out += "-";
|
||||
out += alphabet[bytes[i]! % alphabet.length];
|
||||
const limit = 256 - (256 % alphabet.length);
|
||||
const chars: string[] = [];
|
||||
while (chars.length < 20) {
|
||||
for (const b of randomBytes(32)) {
|
||||
if (b >= limit) continue; // the tail that would skew the alphabet
|
||||
chars.push(alphabet[b % alphabet.length]!);
|
||||
if (chars.length === 20) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
return (chars.join("").match(/.{5}/g) ?? []).join("-");
|
||||
}
|
||||
|
||||
export { MASKED };
|
||||
|
||||
Reference in New Issue
Block a user