Stop exposing captcha passphrase

This commit is contained in:
codex
2026-06-11 21:00:03 +02:00
parent ceb8f7edf5
commit bb3753e5ee
3 changed files with 202 additions and 59 deletions
+95 -6
View File
@@ -43,6 +43,46 @@
("json" . "application/json; charset=utf-8")
("svg" . "image/svg+xml")))
(defconst kkkmeet-server--captcha-glyphs
'(("a" . ("01110" "10001" "10001" "11111" "10001" "10001" "10001"))
("b" . ("11110" "10001" "10001" "11110" "10001" "10001" "11110"))
("c" . ("01111" "10000" "10000" "10000" "10000" "10000" "01111"))
("d" . ("11110" "10001" "10001" "10001" "10001" "10001" "11110"))
("e" . ("11111" "10000" "10000" "11110" "10000" "10000" "11111"))
("f" . ("11111" "10000" "10000" "11110" "10000" "10000" "10000"))
("g" . ("01111" "10000" "10000" "10111" "10001" "10001" "01111"))
("h" . ("10001" "10001" "10001" "11111" "10001" "10001" "10001"))
("i" . ("11111" "00100" "00100" "00100" "00100" "00100" "11111"))
("j" . ("00111" "00010" "00010" "00010" "10010" "10010" "01100"))
("k" . ("10001" "10010" "10100" "11000" "10100" "10010" "10001"))
("l" . ("10000" "10000" "10000" "10000" "10000" "10000" "11111"))
("m" . ("10001" "11011" "10101" "10101" "10001" "10001" "10001"))
("n" . ("10001" "11001" "10101" "10011" "10001" "10001" "10001"))
("o" . ("01110" "10001" "10001" "10001" "10001" "10001" "01110"))
("p" . ("11110" "10001" "10001" "11110" "10000" "10000" "10000"))
("q" . ("01110" "10001" "10001" "10001" "10101" "10010" "01101"))
("r" . ("11110" "10001" "10001" "11110" "10100" "10010" "10001"))
("s" . ("01111" "10000" "10000" "01110" "00001" "00001" "11110"))
("t" . ("11111" "00100" "00100" "00100" "00100" "00100" "00100"))
("u" . ("10001" "10001" "10001" "10001" "10001" "10001" "01110"))
("v" . ("10001" "10001" "10001" "10001" "10001" "01010" "00100"))
("w" . ("10001" "10001" "10001" "10101" "10101" "10101" "01010"))
("x" . ("10001" "10001" "01010" "00100" "01010" "10001" "10001"))
("y" . ("10001" "10001" "01010" "00100" "00100" "00100" "00100"))
("z" . ("11111" "00001" "00010" "00100" "01000" "10000" "11111"))
("0" . ("01110" "10001" "10011" "10101" "11001" "10001" "01110"))
("1" . ("00100" "01100" "00100" "00100" "00100" "00100" "01110"))
("2" . ("01110" "10001" "00001" "00010" "00100" "01000" "11111"))
("3" . ("11110" "00001" "00001" "01110" "00001" "00001" "11110"))
("4" . ("10010" "10010" "10010" "11111" "00010" "00010" "00010"))
("5" . ("11111" "10000" "10000" "11110" "00001" "00001" "11110"))
("6" . ("01111" "10000" "10000" "11110" "10001" "10001" "01110"))
("7" . ("11111" "00001" "00010" "00100" "01000" "01000" "01000"))
("8" . ("01110" "10001" "10001" "01110" "10001" "10001" "01110"))
("9" . ("01110" "10001" "10001" "01111" "00001" "00001" "11110"))
("-" . ("00000" "00000" "00000" "11111" "00000" "00000" "00000"))
("_" . ("00000" "00000" "00000" "00000" "00000" "00000" "11111"))))
(defun kkkmeet-server--uuid ()
"Return a UUID-like random peer id."
(let ((hex (md5 (format "%s-%s-%s" (current-time) (random) (emacs-pid)))))
@@ -97,6 +137,44 @@
"Return non-nil when VALUE matches the configured captcha passphrase."
(equal (or value "") (kkkmeet-server--captcha-passphrase)))
(defun kkkmeet-server--captcha-svg ()
"Return an SVG captcha image for the configured passphrase."
(let* ((cell 4)
(gap 4)
(padding 10)
(passphrase (downcase (kkkmeet-server--captcha-passphrase)))
(chars (append passphrase nil))
(width (max 160 (- (+ (* 2 padding)
(* (length chars) (+ (* 5 cell) gap)))
gap)))
(height 42)
rects)
(cl-loop for char in chars
for index from 0
for key = (char-to-string char)
for glyph = (or (cdr (assoc key kkkmeet-server--captcha-glyphs))
(cdr (assoc "_" kkkmeet-server--captcha-glyphs)))
do (cl-loop for row in glyph
for y from 0
do (cl-loop for pixel across row
for x from 0
when (eq pixel ?1)
do (push
(format
"<rect x=\"%s\" y=\"%s\" width=\"%s\" height=\"%s\" rx=\"1\"/>"
(+ padding (* index (+ (* 5 cell) gap)) (* x cell))
(+ 7 (* y cell))
(1- cell)
(1- cell))
rects))))
(format "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%s\" height=\"%s\" viewBox=\"0 0 %s %s\">
<rect width=\"100%%\" height=\"100%%\" fill=\"#eef2f6\"/>
<path d=\"M0 34 C35 2 78 44 %s 12\" stroke=\"rgba(24,32,42,0.22)\" stroke-width=\"1.5\" fill=\"none\"/>
<g fill=\"#18202a\" transform=\"skewX(-4)\">%s</g>
</svg>"
width height width height width (string-join (nreverse rects) ""))))
(defun kkkmeet-server--nonnegative-number (value)
"Return VALUE as a nonnegative number, or zero."
(max 0 (if (numberp value) value 0)))
@@ -276,11 +354,19 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(when-let ((query (cadr (split-string target "?" t))))
(cadr (assoc name (url-parse-query-string query)))))
(defun kkkmeet-server--handle-config (process)
"Send public runtime configuration to PROCESS."
(defun kkkmeet-server--handle-captcha (process method)
"Send the captcha image to PROCESS."
(kkkmeet-server--send-response
process 200
'(("Content-Type" . "image/svg+xml; charset=utf-8")
("Cache-Control" . "no-store"))
(unless (equal method "HEAD")
(kkkmeet-server--captcha-svg))))
(defun kkkmeet-server--handle-captcha-verify (process answer)
"Send captcha verification result for ANSWER to PROCESS."
(kkkmeet-server--send-json
process 200 `((captcha . ((enabled . t)
(passphrase . ,(kkkmeet-server--captcha-passphrase)))))))
process 200 `((ok . ,(if (kkkmeet-server--captcha-accepted-p answer) t :false)))))
(defun kkkmeet-server--handle-events (process room-id display-name client-id captcha)
"Attach PROCESS to ROOM-ID as an SSE client."
@@ -434,8 +520,11 @@ When KEEP-OPEN is nil, close PROCESS after writing."
(path (alist-get 'path request))
(body (alist-get 'body request)))
(cond
((and (equal method "GET") (equal path "/config"))
(kkkmeet-server--handle-config process))
((and (member method '("GET" "HEAD")) (equal path "/captcha.svg"))
(kkkmeet-server--handle-captcha process method))
((and (equal method "GET") (equal path "/captcha/verify"))
(kkkmeet-server--handle-captcha-verify
process (kkkmeet-server--query-param target "answer")))
((and (equal method "GET") (string-prefix-p "/events/" path))
(kkkmeet-server--handle-events
process
+28 -47
View File
@@ -79,7 +79,6 @@ let watchApplySequence = 0;
let youtubePlaybackRequestId = 0;
let watchPlaylist = [];
let remoteParticipantCount = 0;
let captchaPassphrase = "kkkmeet";
const peers = new Map();
function persistentClientId() {
@@ -216,7 +215,10 @@ function openNameDialog(mode) {
captchaInput.required = !renaming;
if (!renaming) {
captchaInput.value = localStorage.getItem(captchaAnswerStorageKey) || "";
drawCaptchaPassphrase();
drawCaptchaPassphrase().catch((error) => {
console.error(error);
setStatus("Could not load captcha image.");
});
}
nameDialog.dataset.mode = mode;
nameDialog.showModal();
@@ -224,19 +226,7 @@ function openNameDialog(mode) {
initialNameInput.select();
}
async function loadRuntimeConfig() {
try {
const response = await fetch("/config", { cache: "no-store" });
if (!response.ok) throw new Error("Config request failed");
const config = await response.json();
captchaPassphrase = String(config?.captcha?.passphrase || "kkkmeet");
} catch (error) {
console.warn("Could not load runtime config, using default captcha passphrase.", error);
captchaPassphrase = "kkkmeet";
}
}
function drawCaptchaPassphrase() {
async function drawCaptchaPassphrase() {
const context = captchaCanvas.getContext("2d");
if (!context) return;
@@ -246,37 +236,27 @@ function drawCaptchaPassphrase() {
context.fillStyle = "#eef2f6";
context.fillRect(0, 0, width, height);
for (let x = -20; x < width; x += 22) {
context.strokeStyle = x % 44 === 0 ? "rgba(31, 143, 95, 0.25)" : "rgba(31, 90, 143, 0.22)";
context.lineWidth = 2;
context.beginPath();
context.moveTo(x, height);
context.lineTo(x + 34, 0);
context.stroke();
}
context.fillStyle = "#18202a";
context.font = "700 19px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
context.textAlign = "center";
context.textBaseline = "middle";
context.setTransform(1, -0.04, 0.05, 1, 0, 0);
context.fillText(captchaPassphrase, width / 2, height / 2 + 2);
context.setTransform(1, 0, 0, 1, 0, 0);
context.strokeStyle = "rgba(24, 32, 42, 0.22)";
context.lineWidth = 1;
context.beginPath();
context.moveTo(8, height / 2 + 7);
context.bezierCurveTo(44, 8, 92, 48, width - 8, 16);
context.stroke();
const image = new Image();
image.decoding = "async";
image.src = `/captcha.svg?${Date.now()}`;
await image.decode();
context.clearRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
}
function captchaAnswer() {
return String(captchaInput.value || localStorage.getItem(captchaAnswerStorageKey) || "").trim();
}
function captchaIsCorrect() {
return captchaAnswer() === captchaPassphrase;
async function captchaIsCorrect() {
const answer = captchaAnswer();
if (!answer) return false;
const params = new URLSearchParams({ answer });
const response = await fetch(`/captcha/verify?${params}`, { cache: "no-store" });
if (!response.ok) return false;
const result = await response.json();
return Boolean(result.ok);
}
function labelForPeer(peer) {
@@ -1345,7 +1325,7 @@ async function joinCall() {
openNameDialog("join");
return;
}
if (!captchaIsCorrect()) {
if (!(await captchaIsCorrect())) {
setStatus("Enter the captcha passphrase to join the call.");
openNameDialog("join");
captchaInput.focus();
@@ -1500,12 +1480,15 @@ async function stopScreenShare() {
setActivity(displayName, "stopped sharing their screen.");
}
initialNameForm.addEventListener("submit", (event) => {
initialNameForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (nameDialog.dataset.mode !== "rename" && !captchaIsCorrect()) {
if (nameDialog.dataset.mode !== "rename" && !(await captchaIsCorrect())) {
localStorage.removeItem(captchaAnswerStorageKey);
captchaInput.value = "";
drawCaptchaPassphrase();
drawCaptchaPassphrase().catch((error) => {
console.error(error);
setStatus("Could not load captcha image.");
});
setStatus("Captcha passphrase did not match.");
captchaInput.focus();
return;
@@ -1662,12 +1645,10 @@ selfMicMeter.value = 0;
noiseSuppressionInput.checked = savedNoiseSuppression();
async function initializeApp() {
await loadRuntimeConfig();
const savedName = localStorage.getItem(nameStorageKey);
if (savedName) {
setDisplayName(savedName);
if (captchaIsCorrect()) {
if (await captchaIsCorrect()) {
joinCall().catch((error) => {
console.error(error);
setStatus("Could not start camera or join the call.");
+79 -6
View File
@@ -11,6 +11,46 @@ const rooms = new Map();
const watchStates = new Map();
const presentationStates = new Map();
const defaultRoomId = "main";
const captchaGlyphs = {
a: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
b: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
c: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
d: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
e: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
f: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
g: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
h: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
i: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
j: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"],
k: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
l: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
m: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
n: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
o: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
p: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
r: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
s: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
t: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
u: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
v: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
w: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
x: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
0: ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
1: ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
2: ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
3: ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
4: ["10010", "10010", "10010", "11111", "00010", "00010", "00010"],
5: ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
6: ["01111", "10000", "10000", "11110", "10001", "10001", "01110"],
7: ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
8: ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
9: ["01110", "10001", "10001", "01111", "00001", "00001", "11110"],
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
_: ["00000", "00000", "00000", "00000", "00000", "00000", "11111"]
};
const mimeTypes = {
".html": "text/html; charset=utf-8",
@@ -53,6 +93,35 @@ function captchaAccepted(value) {
return String(value || "") === captchaPassphrase;
}
function captchaSvg() {
const cell = 4;
const gap = 4;
const padding = 10;
const chars = [...captchaPassphrase.toLowerCase()];
const width = Math.max(160, padding * 2 + chars.length * (5 * cell + gap) - gap);
const height = 42;
const rects = [];
chars.forEach((char, index) => {
const glyph = captchaGlyphs[char] || captchaGlyphs._;
const xOffset = padding + index * (5 * cell + gap);
glyph.forEach((row, y) => {
[...row].forEach((pixel, x) => {
if (pixel === "1") {
rects.push(`<rect x="${xOffset + x * cell}" y="${7 + y * cell}" width="${cell - 1}" height="${cell - 1}" rx="1"/>`);
}
});
});
});
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<rect width="100%" height="100%" fill="#eef2f6"/>
<path d="M0 34 C35 2 78 44 ${width} 12" stroke="rgba(24,32,42,0.22)" stroke-width="1.5" fill="none"/>
<g fill="#18202a" transform="skewX(-4)">${rects.join("")}</g>
</svg>`;
}
function normalizePlaylist(playlist) {
if (!Array.isArray(playlist)) return [];
@@ -190,13 +259,17 @@ const server = http.createServer(async (req, res) => {
return;
}
if (req.method === "GET" && url.pathname === "/config") {
sendJson(res, 200, {
captcha: {
enabled: true,
passphrase: captchaPassphrase
}
if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/captcha.svg") {
res.writeHead(200, {
"content-type": "image/svg+xml; charset=utf-8",
"cache-control": "no-store"
});
res.end(req.method === "HEAD" ? undefined : captchaSvg());
return;
}
if (req.method === "GET" && url.pathname === "/captcha/verify") {
sendJson(res, 200, { ok: captchaAccepted(url.searchParams.get("answer")) });
return;
}