📜 ⬆️ ⬇️

Codecha - programmer captcha, or how not to design an API

On my website I use Codecha - a programmer captcha. This is a unique captcha, which requires writing the body of a function that solves the problem in one of the selected programming languages.

KDPV - widget of this very captcha

It does not provide as reliable protection against bots as, for example, Google reCAPTCHA (the task set is limited and can be resolved quite quickly and then ready answers are given), but it helps against non-programmers (for a certain category of forums, crowds of students asking doing their laboratory work is a real problem, worse than spam bots). But the note is not about that.
')
The problem I encountered while using Codecha is a completely inconvenient API for both the captcha widget and the server.

To begin with, I propose to briefly look at how to connect the widget. I will not copy the documentation, only the most important thing.

So, we register, somewhere in the form we add the following code:

<script type="text/javascript" src="//codecha.org/api/challenge?k=YOUR_PUBLIC_KEY"> </script> 

With the widget all. Now, when submitting the form, you need to get the codecha_challenge_field and codecha_response_field fields , and then send them to the server for verification. Hereinafter, the server code will be on Node.js.

Parsing the form, calling the check function (using promises and q-io ):

 var HTTP = require("q-io/http"); var checkCaptcha = function(req, fields) { var challenge = fields.codecha_challenge_field; var response = fields.codecha_response_field; if (!challenge) return Promise.reject("Captcha challenge is empty"); if (!response) return Promise.reject("Captcha is empty", "error"); var body = `challenge=${challenge}&response=${response}&remoteip=${req.ip}&privatekey=PRIVTE_KEY`; var url = "http://codecha.org/api/verify"; return HTTP.request({ url: url, method: "POST", body: [body], headers: { "Content-Type": "application/x-www-form-urlencoded", "Content-Length": Buffer.byteLength(body) }, timeout: (15 * 1000) //15  }).then(function(response) { if (response.status != 200) return Promise.reject("Failed to check captcha"); return response.body.read("utf8"); }).then(function(data) { var result = data.toString(); if (result.replace("true") == result) return Promise.reject("Invalid captcha"); return Promise.resolve(); }); }; 

If the test is successful, the string “true” will be returned, otherwise - the string “false” .

It would seem that everything is simple, what issues can there be at all? But no. The fun begins when the user has sent one message using AJAX and wants to send the next one without refreshing the page. For this reCAPTCHA, for example, provides the method grecaptcha.reset , but Codecha does not have such a method. When solving a captcha, all its HTML is deleted, leaving only the text, indicating successful completion of the task. You have to update the page.

The same thing happens if, for example, an error occurred on the server when checking the captcha on the server. In this case, the task-task pair on the Codecha server is invalid, and a repeated check of the same pair will return “false” . The user will again have to refresh the page (in this case, he will also lose all the data entered into the form).

Not good at it. Maybe there is some hidden widget API? It should be. But no.

We look at the code of the script that was inserted into the form
 var codecha = { language: "PHP", publicKey: "e37ea65c651a4eada4d5d4e97ae92d90", fieldPrefix: "codecha_", base_url: "//codecha.org", spinner_path: "/static/ajax-loader.gif", css_path: "/static/widget.css", survey: true, callbacks: {}, }; codecha.callbacks.hideErrorOverlay = function() { codecha.errorOverlayDiv.hidden = true; return false; } codecha.callbacks.codeSubmit = function() { var xhr = codecha.CORCSRequest(codecha.base_url + "/api/code"); codecha.disable(); var params = { 'challenge': codecha.challenge, 'code': codecha.codeArea.value }; xhr.send(codecha.serialize(params)); return false; }; codecha.callbacks.choseLanguage = function() { codecha.languageSelector.hidden = false; codecha.languageSelector.style.display = ''; codecha.changeChallenge.value = "\u2713"; codecha.button.disabled = true; codecha.changeChallenge.onclick = codecha.callbacks.requestNewChallenge; return false; }; codecha.callbacks.requestNewChallenge = function() { codecha.disable(); codecha.setStatus("waiting"); codecha.languageSelector.hidden = true; codecha.languageSelector.style.display = 'none'; codecha.changeChallenge.value = "change lang"; var lang = codecha.languageSelector[codecha.languageSelector.selectedIndex].value; var xhr = codecha.CORCSRequest(codecha.base_url + "/api/change"); var params = { 'challenge': codecha.challenge, 'k': codecha.publicKey, 'lang': lang }; xhr.send(codecha.serialize(params)); codecha.changeChallenge.onclick = codecha.callbacks.choseLanguage; return false; }; codecha.callbacks.textAreaKeyPress = function(ev) { object = codecha.codeArea; if (ev.keyCode == 9) { start = object.selectionStart; end = object.selectionEnd; object.value = object.value.substring(0, start) + "\t" + object.value.substr(end); object.setSelectionRange(start + 1, start + 1); object.selectionStart = object.selectionEnd = start + 1; return false; } return true; }; codecha.callbacks.updateState = function() { var xhr = codecha.CORCSRequest(codecha.base_url + "/api/state"); xhr.send(codecha.serialize({ 'challenge': codecha.challenge })); return false; }; codecha.callbacks.sendSurvey = function() { var xhr = codecha.CORCSRequest(codecha.base_url + "/api/survey"); var mark = codecha.surveyMark[codecha.surveyMark.selectedIndex].value; var params = { 'challenge': codecha.challenge, 'response': codecha.response, 'mark': mark, 'opinion' : codecha.surveyOpinion.value }; xhr.send(codecha.serialize(params)); return false; }; codecha.callbacks.switchToRecaptcha = function() { var challengeField = document.getElementById(codecha.fieldPrefix + "challenge_field"); var responseField = document.getElementById(codecha.fieldPrefix + "response_field"); codecha.removeElement(codecha.mainDiv); codecha.removeElement(codecha.recaptchaSwitch); codecha.removeElement(challengeField); codecha.removeElement(responseField); codecha.recaptchaDiv.hidden = false; var xhr = codecha.CORCSRequest(codecha.base_url + "/api/recaptchify"); var params = { 'challenge': codecha.challenge }; xhr.send(codecha.serialize(params)); return false; }; codecha.removeElement = function(element) { element.parentElement.removeChild(element); }; codecha.removeRecatpcha = function() { this.removeElement(this.recaptchaDiv); }; codecha.escape = function(str) { var div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; }; codecha.serialize = function(obj) { array = []; for (key in obj) { array[array.length] = encodeURIComponent(key) + "=" + encodeURIComponent(obj[key]); } var result = array.join("&"); result = result.replace(/%20/g, "+"); return result; }; codecha.enable = function() { this.button.disabled = false; this.changeChallenge.disabled = false; this.codeArea.disabled = false; this.spinner.hidden = true; }; codecha.disable = function() { this.button.disabled = true; this.changeChallenge.disabled = true; this.codeArea.disabled = true; this.spinner.hidden = false; }; codecha.inject_css = function() { var css_link=document.createElement("link"); css_link.setAttribute("rel", "stylesheet"); css_link.setAttribute("type", "text/css"); css_link.setAttribute("href", codecha.base_url + codecha.css_path); document.getElementsByTagName("head")[0].appendChild(css_link); }; codecha.setStatus = function(state) { this.statusSpan.innerHTML = state; }; codecha.setResponseFields = function() { var challengeField = document.getElementById(this.fieldPrefix + "challenge_field"); var responseField = document.getElementById(this.fieldPrefix + "response_field"); challengeField.value = this.challenge; responseField.value = this.response; }; codecha.setChallenge = function(uuid, language_name, wording, top, sampleCode, bottom) { this.challenge = uuid; this.wordingDiv.innerHTML = "<strong>" + language_name + ":</strong> " + wording; this.codeAreaTop.innerHTML = "<pre>\n"+this.escape(top)+"</pre>"; this.codeArea.value = sampleCode; this.codeAreaBottom.innerHTML = this.escape(bottom); if (top.length > 0) { this.codeAreaTop.hidden = false; } else { this.codeAreaTop.hidden = true; } if (bottom.length > 0) { this.codeAreaBottom.hidden = false; } else { this.codeAreaBottom.hidden = true; } }; codecha.showErrorMessage = function(message) { this.errorMessageDiv.innerHTML = message; this.errorOverlayDiv.hidden = false; }; codecha.showSurvey = function() { codecha.mainDiv.innerHTML = "\ <strong>Challenge completed! You may proceed.</strong> \ If you have some spare time you may help us improve our widget by answearing any question below. \ Challenge was: \ <select id=\"codecha_survey_mark_selector\"> \ <option value=\"5\">a way too hard</option> \ <option value=\"4\">a bit too hard</option> \ <option value=\"3\" selected>perfect</option> \ <option value=\"2\">a bit too easy</option> \ <option value=\"1\">a way too easy</option> \ </select> \ How do you like our widget? \ <textarea id=\"codcha_survey_opinion_area\" name=\"codcha_survey_opinion_area\">I like/dislike it because...</textarea> \ <input type=\"submit\" class=\"codecha_button\" name=\"codecha_survey_submit\" id=\"codecha_survey_submit\" value=\"SUBMIT\"/>\ "; codecha.surveySubmit = document.getElementById("codecha_survey_submit"); codecha.surveyMark = document.getElementById("codecha_survey_mark_selector"); codecha.surveyOpinion = document.getElementById("codcha_survey_opinion_area"); codecha.surveySubmit.onclick = codecha.callbacks.sendSurvey; }; codecha.CORCSRequest = function (url) { var xhr = new XMLHttpRequest(); if ("withCredentials" in xhr) { xhr.open("POST", url, true); } else if (typeof XDomainRequest != "undefined") { xhr = new XDomainRequest(); xhr.open("POST", url); } else { xhr = null; } xhr.onload = function() { eval(this.responseText); }; xhr.onerror = function() { alert("Error!"); codecha.enable(); }; xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded"); return xhr; }; codecha.init = function() { document.write( "<input type=\"hidden\" id=\"" + this.fieldPrefix + "challenge_field\" /name=\"" + this.fieldPrefix + "challenge_field\" />", "<input type=\"hidden\" id=\"" + this.fieldPrefix + "response_field\" name=\"" + this.fieldPrefix + "response_field\" />", "<div id=\"codecha_widget\">", "<div id=\"codecha_error_overlay\">", "<a href=\"#\" id=\"codecha_error_overlay_hide\">hide</a>", "<div id=\"codecha_error_message\"></div>", "</div>", "<div id=\"codecha_wording\"></div>", "<div id=\"codecha_code_area_top\"></div>", "<textarea name=\"codecha_code_area\" id=\"codecha_code_area\">", "</textarea>", "<div id=\"codecha_code_area_bottom\"></div>", "<div id=\"codecha_bottom_container\">", "<a title=\"click to learn more\" href=\"" + codecha.base_url + "/about\" target=\"_blank\" id=\"codecha_about\">Codecha</a>", "<div id=\"codecha_bottom\">", "<span id=\"codecha_spinner\">", "<span id=\"codecha_status\">waiting</span>", "<img alt=\"spinner\" src=\"" + codecha.base_url + codecha.spinner_path + "\" />", "</span>", "<select id=\"codecha_language_selector\">", "<option value=\"c\" >C/C++</option>", "<option value=\"java\" >Java</option>", "<option value=\"python\" >Python</option>", "<option value=\"ruby\" >Ruby</option>", "<option value=\"php\" selected >PHP</option>", "<option value=\"haskell\" >Haskell</option>", "</select>", "<input type=\"submit\" class=\"codecha_button\" name=\"codecha_change_challenge\" id=\"codecha_change_challenge\" title=\"request new challenge\" value=\"change lang\"/>", "<input type=\"submit\" class=\"codecha_button\" name=\"codecha_code_submit_button\" id=\"codecha_code_submit_button\" value=\"VERIFY\"/>", "</div>", "</div>", "</div>", "<div id=\"codecha_recaptcha\">", "</div>" ); this.mainDiv = document.getElementById("codecha_widget"); this.codeArea = document.getElementById("codecha_code_area"); this.codeAreaTop = document.getElementById("codecha_code_area_top"); this.codeAreaBottom = document.getElementById("codecha_code_area_bottom"); this.wordingDiv = document.getElementById("codecha_wording"); this.errorOverlayDiv = document.getElementById("codecha_error_overlay"); this.errorMessageDiv = document.getElementById("codecha_error_message"); this.errorHide = document.getElementById("codecha_error_overlay_hide"); this.button = document.getElementById("codecha_code_submit_button"); this.spinner = document.getElementById("codecha_spinner"); this.statusSpan = document.getElementById("codecha_status"); this.languageSelector = document.getElementById("codecha_language_selector"); this.recaptchaDiv = document.getElementById("codecha_recaptcha"); this.changeChallenge = document.getElementById("codecha_change_challenge"); this.changeChallenge.onclick = codecha.callbacks.choseLanguage; this.button.onclick = codecha.callbacks.codeSubmit; this.errorHide.onclick = codecha.callbacks.hideErrorOverlay; this.codeArea.onkeydown = codecha.callbacks.textAreaKeyPress; this.errorOverlayDiv.hidden = true; this.spinner.hidden = true; this.languageSelector.hidden = true; this.languageSelector.style.display = 'none'; this.inject_css(); this.enable(); codecha.setChallenge("d847842d3225459582722c8695ef8523", "PHP", "For given numbers \u0022a\u0022 and \u0022b\u0022 write a function named \u0022lessab\u0022 that returns the value \u00221\u0022 if \u0022a\u0022 is less than \u0022b\u0022, and returns the value \u00220\u0022 otherwise.\u000A", "function lessab($a,$b) {\u000A", "# put your code here\u000A", "\u000A}\u000A"); }; codecha.init(); 


Open the spoiler, press Ctrl + F and enter "codecha.init". Yes, yes it is true. You can try it yourself. This is exactly the HTML code as a string, which is added to the page using document.write . Don't even ask me what's wrong with that.

But that's not all! (c) advertising miracle devices

How does, for example, change the language? Send a request, and then ... a drum roll ...

 xhr.onload = function() { eval(this.responseText); }; 

Oh yeah! It would be possible to finish here, but I decided to finish the cactus to the end. For this, I wrote my wrapper on Codecha. I will not go into too much rant, I will immediately give the code with explanations.

So, HTML widget:

 <div id="captcha" class="codechaContainer"> <input type="hidden" id="codecha_public_key" value="PUBLIC_KEY" /> <input type="hidden" id="codecha_challenge_field" name="codecha_challenge_field" value="CHALLENGE" /> <input type="hidden" id="codecha_response_field" name="codecha_response_field" /> <div id="codecha_ready_widget" style="display: none;"><strong>Challenge completed! You may proceed.</strong></div> <div id="codecha_widget"> <div id="codecha_error_overlay" hidden="true"> <a id="codecha_error_overlay_hide" href="javascript:void(0);" onclick="codecha.hideErrorOverlay();">hide</a> <div id="codecha_error_message"></div> </div> <div id="codecha_wording"></div> <div id="codecha_code_area_top"></div> <textarea id="codecha_code_area" name="codecha_code_area"></textarea> <div id="codecha_code_area_bottom"></div> <div id="codecha_bottom_container"> <a title="click to learn more" href="//codecha.org/about" target="_blank" id="codecha_about">Codecha</a> <div id="codecha_bottom"> <span id="codecha_spinner" hidden="true"> <span id="codecha_status">waiting</span><img src="//codecha.org/static/ajax-loader.gif" /> </span> <select id="codecha_language_selector" hidden="true" style="display: none;"> <option value="c" selected="true">C/C++</option> <option value="java">Java</option> <option value="python">Python</option> <option value="ruby">Ruby</option> <option value="php">PHP</option> <option value="haskell">Haskell</option> </select> <input type="submit" class="codecha_button" name="codecha_change_challenge" id="codecha_change_challenge" title="request new challenge" value="change lang" onclick="codecha.chooseLanguage(); return false;" /> <input type="submit" class="codecha_button" name="codecha_code_submit_button" id="codecha_code_submit_button" value="VERIFY" onclick="codecha.codeSubmit(); return false;" /> </div> </div> </div> </div> 

Client Script:

 var codecha = {}; //     function id(_id) { return document.getElementById(_id); } function node(type, text) { if (typeof type != "string") return null; type = type.toUpperCase(); return ("TEXT" == type) ? document.createTextNode(text ? text : "") : document.createElement(type); }; function post(action, data) { return $.ajax(action, { type: "POST", data: data, dataType: "text" }); }; codecha.mustRequestNewChallenge = false; codecha.serialize = function(obj) { var array = []; for (key in obj) array[array.length] = encodeURIComponent(key) + "=" + encodeURIComponent(obj[key]); var result = array.join("&"); result = result.replace(/%20/g, "+"); return result; }; codecha.escape = function(str) { var div = node("div"); div.appendChild(node("text", str)); return div.innerHTML; }; codecha.enable = function() { id("codecha_code_submit_button").disabled = false; id("codecha_change_challenge").disabled = false; id("codecha_code_area").disabled = false; id("codecha_spinner").hidden = true; }; codecha.disable = function() { id("codecha_code_submit_button").disabled = true; id("codecha_change_challenge").disabled = true; id("codecha_code_area").disabled = true; id("codecha_spinner").hidden = false; }; codecha.hideErrorOverlay = function() { id("codecha_error_overlay").hidden = true; } codecha.setStatus = function(state) { id("codecha_status").innerHTML = state; }; codecha.updateState = function() { post("//codecha.org/api/state", codecha.serialize({ 'challenge': id("codecha_challenge_field").value })).then(function(response) { var match = /codecha\.response\s*\=\s"([^"]+)"/gi.exec(response); if (match) { codecha.mustRequestNewChallenge = true; id("codecha_response_field").value = match[1]; id("codecha_widget").style.display = "none"; id("codecha_ready_widget").style.display = ""; } else { eval(response.replace(".callbacks", "")); } }).catch(function(err) { console.log(err); }); }; codecha.showErrorMessage = function(message) { id("codecha_error_message").innerHTML = message; id("codecha_error_overlay").hidden = false; }; codecha.setChallenge = function(uuid, langName, wording, top, sampleCode, bottom) { id("codecha_challenge_field").value = uuid; id("codecha_wording").innerHTML = "<strong>" + langName + ":</strong> " + wording; id("codecha_code_area_top").innerHTML = "<pre>\n"+this.escape(top)+"</pre>"; id("codecha_code_area").value = sampleCode; id("codecha_code_area_bottom").innerHTML = codecha.escape(bottom); id("codecha_code_area_top").hidden = (top.length <= 0); id("codecha_code_area_bottom").hidden = (bottom.length <= 0); }; codecha.choseLanguage = function() { id("codecha_language_selector").hidden = false; id("codecha_language_selector").style.display = ""; id("codecha_change_challenge").value = "\u2713"; id("codecha_code_submit_button").disabled = true; id("codecha_change_challenge").onclick = codecha.requestNewChallenge; return false; }; codecha.requestNewChallenge = function() { codecha.disable(); codecha.setStatus("waiting"); var select = id("codecha_language_selector"); select.hidden = true; select.style.display = "none"; id("codecha_change_challenge").value = "change lang"; id("codecha_change_challenge").onclick = codecha.choseLanguage; id("codecha_response_field").value = ""; var p; if (!codecha.mustRequestNewChallenge) { p = Promise.resolve(); } else { p = Promise.resolve().then(function() { return $.getJSON("/api/codechaChallenge.json"); }).then(function(model) { codecha.mustRequestNewChallenge = false; id("codecha_challenge_field").value = model; return Promise.resolve(); }); } p.then(function() { var params = { "challenge": id("codecha_challenge_field").value, "k": id("codecha_public_key").value, "lang": select.options[select.selectedIndex].value }; return post("//codecha.org/api/change", codecha.serialize(params)); }).then(function(response) { eval(response); }).catch(function(err) { console.log(err); }); return false; }; codecha.codeSubmit = function() { codecha.disable(); var params = { "challenge": id("codecha_challenge_field").value, "code": id("codecha_code_area").value }; post("//codecha.org/api/code", codecha.serialize(params)).then(function(response) { codecha.setStatus("sending"); setTimeout(codecha.updateState, 1000); }).catch(function(err) { console.log(err); }); }; (function() { var link = node("link"); link.setAttribute("rel", "stylesheet"); link.setAttribute("type", "text/css"); link.setAttribute("href", "//codecha.org/static/widget.css"); document.querySelector("head").appendChild(link); })(); window.addEventListener("load", function load() { window.removeEventListener("load", load, false); codecha.disable(); codecha.requestNewChallenge(); }, false); var reloadCaptcha = function() { codecha.requestNewChallenge(); id("codecha_widget").style.display = ""; id("codecha_ready_widget").style.display = "none"; }; 

Server code:

 var requestChallenge = function(req) { var url = `http://codecha.org/api/challenge?k=PUBLIC_KEY`; return HTTP.request({ url: url, timeout: (15 * 1000) }).then(function(response) { if (response.status != 200) return Promise.reject("Failed to get challenge"); return response.body.read("utf8"); }).then(function(data) { var match = /codecha.setChallenge\("([^"]+)"/gi.exec(data.toString()); if (!match) return Promise.reject("Captcha server error"); return Promise.resolve(match[1]); }); }; 

Now let's deal with what is happening here. First, we refer to the very address of the script that is inserted into the form, and with the help of a regular expression we isolate the task identifier. Then we substitute the identifier into the HTML template and finally give the page to the user.

The client script, in turn, sends a request to switch jobs. It is important to note that when requesting a new job, you must pass the ID of the previous one. We get a new task, we substitute it in the widget. But the most interesting thing next.

After the user enters the task and the Codecha server checks it (everything is almost identical to the original widget), we DO NOT remove the interface elements, but simply hide them. After sending an AJAX request message to the server, we can call the reloadCaptcha function, which will request the task ID from our server, and then, using its ID, will receive a new task from the Codecha server and fill the widget again. The user at the same time feels dry and comfortable, because the page is not required to refresh.

In the end I would like to give some recommendations:

  1. Never, NEVER use eval to interpret a response from a server. This will only add headaches to developers, but will not help protect the API from their intervention.
  2. Think about how your product will be used. Consider all the options, even the most implausible (although the case described in the note is quite mundane). Try to make the API suitable for use in all situations.
  3. Do not tie the receipt of a new, in no way related to the previous, piece of data to the information about the previous piece of data. This, again, will only add a headache, but will not protect against anything.
  4. If you decide to protect the API from interference, then at least use obfuscation. Without it, it is a meaningless waste of his and others time.

Source: https://habr.com/ru/post/272129/


All Articles