diff --git a/cert/index.html b/cert/index.html
new file mode 100644
index 0000000..f3df652
--- /dev/null
+++ b/cert/index.html
@@ -0,0 +1,46 @@
+
+
+
+ SHRoNK IP Specification test
+
+
+
+
+ SHRoNK IP Online specification test
+ This tool allows you to check the specification-following status of a SHRoNK IP server.
+
+ This online tool allows you to quickly verify which parts of the
+ SHRoNK IP specification
+ are properly implemented by a SHRoNK IP server.
+
+
+ Warning! This tool cannot test for non-CORS enabled servers!
+ If the server does not have CORS enabled, this tool will not be able to test the server.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/cert/script.js b/cert/script.js
new file mode 100644
index 0000000..c4466d1
--- /dev/null
+++ b/cert/script.js
@@ -0,0 +1,185 @@
+const VALID_KEYS = {
+ ip: {
+ required: true,
+ match: /^(\d{1,3}\.){3}\d{1,3}$/
+ },
+ city: {
+ required: false,
+ match: /^[a-zA-Z ]+$/
+ },
+ country: {
+ required: false,
+ match: /^[a-zA-Z ]+$/
+ },
+ countryCode: {
+ required: false,
+ match: /^[A-Z]{2}$/
+ },
+ asn: {
+ required: false,
+ match: /^\d+$/
+ },
+ isp: {
+ required: false,
+ match: /^.+$/ // ISP names can be anything really
+ },
+ lat: {
+ required: false,
+ match: /^-?\d+(\.\d+)?$/
+ },
+ lon: {
+ required: false,
+ match: /^-?\d+(\.\d+)?$/
+ },
+ hostname: {
+ required: false,
+ match: /^.+$/
+ },
+ timezone: {
+ required: false,
+ match: /^[^\/]\/.+$/
+ },
+ locale: {
+ required: false,
+ match: /^[a-z]{2}([_-][A-Z]{2})?$/
+ },
+ subnet: {
+ required: false,
+ match: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/
+ },
+ abuse: {
+ required: false,
+ match: /^.+$/
+ }
+}
+const BOBS = [
+ "|",
+ "/",
+ "-",
+ "\\"
+]
+
+async function isCORSEnabled(url) {
+ try {
+ const response = await fetch(url, { method: "GET", mode: "cors"});
+ return response.ok;
+ } catch {
+ return false;
+ };
+}
+
+
+async function getRealIP() {
+ const response = await fetch("https://api.ipify.org?format=json");
+ const data = await response.json();
+ return data.ip;
+
+}
+
+
+async function checkRaw(url, real) {
+ const response = await fetch(url);
+ const text = await response.text();
+ return text === real;
+}
+
+async function checkRoot(url) {
+ const response = await fetch(url);
+ const data = await response.json();
+ return data
+}
+
+async function checkLookup(url) {
+ const response = await fetch(url);
+ const data = await response.json();
+ return data
+}
+
+async function checkImFeelingLucky(url) {
+ const response = await fetch(url);
+ const data = await response.json();
+ return data;
+}
+
+function checkReturnedJSON(data) {
+ let dataIncluded = 0;
+ for (let key in VALID_KEYS) {
+ if (data[key]) {
+ if(VALID_KEYS[key].match.test(data[key])) {
+ console.debug("Server included key %s (value: '%s')", key, data[key]);
+ dataIncluded++;
+ } else {
+ console.warn("Server included key %s (value: '%s'), but it's not valid", key, data[key]);
+ }
+ } else {
+ if (VALID_KEYS[key].required) {
+ console.warn("Server didn't include required key %s", key);
+ } else {
+ console.log("Server didn't include optional key %s", key)
+ }
+ }
+ }
+ return dataIncluded;
+}
+
+
+async function main(e) {
+ e.preventDefault();
+ const base_url = document.querySelector("input").value;
+ const log = document.getElementById("results");
+ log.textContent = "Checking: " + base_url + "\n";
+ log.textContent += "Checking if CORS is enabled on the target... ";
+ const corsEnabled = await isCORSEnabled(base_url);
+ log.textContent += corsEnabled ? "Yes\n" : "No\n";
+ if (!corsEnabled) {
+ log.textContent += "CORS is not enabled, the script will not work\n";
+ return;
+ }
+
+ log.textContent += "Getting external IP from trusted source... ";
+ const realIP = await getRealIP();
+ log.textContent += realIP + "\n";
+
+ log.textContent += "Checking /... ";
+ const root = await checkRoot(base_url);
+ const rootFeatures = checkReturnedJSON(root)
+ let rootOK = rootFeatures >= 1 && root.ip === realIP;
+ log.textContent += rootOK ? `OK (${rootFeatures}/${Object.keys(VALID_KEYS).length} features)\n` : "FAILED\n";
+
+
+ log.textContent += "Checking /lookup... ";
+ const lookup = await checkLookup(base_url + "/lookup?ip=" + realIP);
+ const lookupFeatures = checkReturnedJSON(lookup)
+ let lookupOK = lookupFeatures >= 1 && lookup.ip === realIP;
+ log.textContent += lookupOK ? `OK (${rootFeatures}/${Object.keys(VALID_KEYS).length} features)\n` : "FAILED\n";
+
+ log.textContent += "Checking /imfeelinglucky (this may take a while) ";
+ let changed = false;
+ for(let i=0; i<512; i++) {
+ console.debug("Checking /imfeelinglucky iteration %d/512", i);
+ const lucky = await checkImFeelingLucky(base_url + "/imfeelinglucky");
+ const luckyFeatures = checkReturnedJSON(lucky)
+ let luckyOK = luckyFeatures >= 1 && lucky.ip != realIP;
+ if (luckyOK) {
+ console.debug("Server modified IP, OK")
+ log.textContent = log.textContent.slice(0, -1)
+ log.textContent += `OK (${luckyFeatures}/${Object.keys(VALID_KEYS).length} features, took ${i} attempts for a modified IP)\n`;
+ changed = true;
+ break;
+ }
+ else {
+ console.debug("Server did not modify IP, retrying")
+ log.textContent = log.textContent.slice(0, -1)
+ log.textContent += BOBS[i % 4];
+ }
+ }
+ if(!changed) {
+ log.textContent += "FAILED (did not modify returned IP within 512 tries)\n";
+ }
+
+ log.textContent += "Checking /raw... ";
+ const raw = await checkRaw(base_url + "/raw", realIP);
+ log.textContent += raw ? "OK\n" : "FAILED\n";
+}
+
+document.querySelector("form").addEventListener("submit", main);