diff --git a/assets/common.js/elem.js b/assets/common.js/elem.js new file mode 100644 index 0000000..cfedc79 --- /dev/null +++ b/assets/common.js/elem.js @@ -0,0 +1,104 @@ +const $e = function(info, attrs, child, created) { + info = info || {}; + + if(typeof info === 'string') { + info = {tag: info}; + if(attrs) + info.attrs = attrs; + if(child) + info.child = child; + if(created) + info.created = created; + } + + const elem = document.createElement(info.tag || 'div'); + + if(info.attrs) { + const attrs = info.attrs; + + for(let key in attrs) { + const attr = attrs[key]; + if(attr === undefined || attr === null) + continue; + + switch(typeof attr) { + case 'function': + if(key.substring(0, 2) === 'on') + key = key.substring(2).toLowerCase(); + elem.addEventListener(key, attr); + break; + + case 'object': + if(attr instanceof Array) { + if(key === 'class') + key = 'classList'; + + const prop = elem[key]; + let addFunc = null; + + if(prop instanceof Array) + addFunc = prop.push.bind(prop); + else if(prop instanceof DOMTokenList) + addFunc = prop.add.bind(prop); + + if(addFunc !== null) { + for(let j = 0; j < attr.length; ++j) + addFunc(attr[j]); + } else { + if(key === 'classList') + key = 'class'; + elem.setAttribute(key, attr.toString()); + } + } else { + for(const attrKey in attr) + elem[key][attrKey] = attr[attrKey]; + } + break; + + default: + if(key === 'className') + key = 'class'; + elem.setAttribute(key, attr.toString()); + break; + } + } + } + + if(info.child) { + let children = info.child; + + if(!Array.isArray(children)) + children = [children]; + + for(const child of children) { + switch(typeof child) { + case 'string': + elem.appendChild($t(child)); + break; + + case 'object': + if(child instanceof Element) + elem.appendChild(child); + else if(child.getElement) { + const childElem = child.getElement(); + if(childElem instanceof Element) + elem.appendChild(childElem); + else + elem.appendChild($e(child)); + } else + elem.appendChild($e(child)); + break; + + default: + elem.appendChild($t(child.toString())); + break; + } + } + } + + if(info.created) + info.created(elem); + + return elem; +}; +const $er = (type, props, ...children) => $e({ tag: type, attrs: props, child: children }); diff --git a/assets/common.js/main.js b/assets/common.js/main.js index ddc6bba..add1108 100644 --- a/assets/common.js/main.js +++ b/assets/common.js/main.js @@ -1 +1,64 @@ -/* common.js */ +const $i = document.getElementById.bind(document); +const $c = document.getElementsByClassName.bind(document); +const $q = document.querySelector.bind(document); +const $qa = document.querySelectorAll.bind(document); +const $t = document.createTextNode.bind(document); + +const $r = function(element) { + if(element && element.parentNode) + element.parentNode.removeChild(element); +}; + +const $ri = function(name) { + $r($i(name)); +}; + +const $ib = function(ref, elem) { + ref.parentNode.insertBefore(elem, ref); +}; + +const $rc = function(element) { + while(element.lastChild) + element.removeChild(element.lastChild); +}; + +const $ar = function(array, index) { + array.splice(index, 1); +}; +const $ari = function(array, item) { + let index; + while(array.length > 0 && (index = array.indexOf(item)) >= 0) + $ar(array, index); +}; +const $arf = function(array, predicate) { + let index; + while(array.length > 0 && (index = array.findIndex(predicate)) >= 0) + $ar(array, index); +}; + +const $as = function(array) { + if(array.length < 2) + return; + + for(let i = array.length - 1; i > 0; --i) { + let j = Math.floor(Math.random() * (i + 1)), + tmp = array[i]; + array[i] = array[j]; + array[j] = tmp; + } +}; + +const $csrfp = (function() { + const elem = $q('meta[name="csrfp-token"]'); + + return { + get: () => elem?.content ?? '', + set: token => { + if(elem != null) + elem.content = token?.toString() ?? ''; + }, + }; +})(); + +#include elem.js +#include xhr.js diff --git a/assets/common.js/xhr.js b/assets/common.js/xhr.js new file mode 100644 index 0000000..c3bb292 --- /dev/null +++ b/assets/common.js/xhr.js @@ -0,0 +1,102 @@ +const $x = (function() { + const send = function(method, url, options, body) { + options ??= {}; + + const xhr = new XMLHttpRequest; + const requestHeadersRaw = options?.headers ?? {}; + const requestHeaders = new Map; + + if(typeof requestHeadersRaw === 'object') + for(const name in requestHeadersRaw) + if(requestHeadersRaw.hasOwnProperty(name)) + requestHeaders.set(name.toLowerCase(), requestHeadersRaw[name]); + + if(typeof options.download === 'function') { + xhr.onloadstart = ev => options.download(ev); + xhr.onprogress = ev => options.download(ev); + xhr.onloadend = ev => options.download(ev); + } + + if(typeof options.upload === 'function') { + xhr.upload.onloadstart = ev => options.upload(ev); + xhr.upload.onprogress = ev => options.upload(ev); + xhr.upload.onloadend = ev => options.upload(ev); + } + + if(options.authed) + xhr.withCredentials = true; + + if(typeof options.timeout === 'number') + xhr.timeout = options.timeout; + + if(typeof options.abort === 'function') + options.abort(() => xhr.abort()); + + if(typeof options.xhr === 'function') + options.xhr(() => xhr); + + if(typeof body === 'object') { + if(body instanceof URLSearchParams) { + requestHeaders.set('content-type', 'application/x-www-form-urlencoded'); + } else if(body instanceof FormData) { + requestHeaders.set('content-type', 'multipart/form-data'); + } else if(body instanceof Blob || body instanceof ArrayBuffer || body instanceof DataView) { + if(!requestHeaders.has('content-type')) + requestHeaders.set('content-type', 'application/octet-stream'); + } else if(!requestHeaders.has('content-type')) { + const bodyParts = []; + for(const name in body) + if(body.hasOwnProperty(name)) + bodyParts.push(encodeURIComponent(name) + '=' + encodeURIComponent(body[name])); + body = bodyParts.join('&'); + requestHeaders.set('content-type', 'application/x-www-form-urlencoded'); + } + } + + return new Promise((resolve, reject) => { + let responseHeaders = undefined; + + xhr.onload = ev => resolve({ + status: xhr.status, + body: () => xhr.responseText, + json: () => JSON.parse(xhr.responseText), + headers: () => { + if(responseHeaders !== undefined) + return responseHeaders; + + responseHeaders = new Map; + + const raw = xhr.getAllResponseHeaders().trim().split(/[\r\n]+/); + for(const name in raw) + if(raw.hasOwnProperty(name)) { + const parts = raw[name].split(': '); + responseHeaders.set(parts.shift(), parts.join(': ')); + } + + return responseHeaders; + }, + xhr: xhr, + ev: ev, + }); + + xhr.onerror = ev => reject({ + xhr: xhr, + ev: ev, + }); + + xhr.open(method, url); + for(const [name, value] of requestHeaders) + xhr.setRequestHeader(name, value); + xhr.send(body); + }); + }; + + return { + send: send, + get: (url, options, body) => send('GET', url, options, body), + post: (url, options, body) => send('POST', url, options, body), + delete: (url, options, body) => send('DELETE', url, options, body), + patch: (url, options, body) => send('PATCH', url, options, body), + put: (url, options, body) => send('PUT', url, options, body), + }; +})(); diff --git a/assets/whois.js/main.js b/assets/whois.js/main.js index 1f18499..42082ed 100644 --- a/assets/whois.js/main.js +++ b/assets/whois.js/main.js @@ -1,11 +1,11 @@ (function() { - var locked = false, - input = document.getElementById('lookup-input'), - submit = document.getElementById('lookup-submit'), - result = document.getElementById('lookup-result'), - tabs = document.getElementById('lookup-tabs'); + let locked = false; + const input = $i('lookup-input'), + submit = $i('lookup-submit'), + result = $i('lookup-result'), + tabs = $i('lookup-tabs'); - var lock = function() { + const lock = function() { if(locked) return false; @@ -15,7 +15,7 @@ return true; } - var unlock = function() { + const unlock = function() { if(!locked) return false; @@ -25,66 +25,66 @@ return true; } - var lookup = function(target) { + const lookup = function(target) { if(!lock()) return; - var xhr = new XMLHttpRequest; - xhr.addEventListener('load', function() { - var resp = JSON.parse(xhr.responseText); + $x.post('/whois/lookup', {}, { _csrfp: $csrfp.get(), target: target }).then(output => { + let headers = output.headers(); + if(headers.has('x-csrfp')) + $csrfp.set(headers.get('x-csrfp')); + let resp = output.json(); if(resp.error) alert(resp.text); + let count = 0; + tabs.innerHTML = ''; - if(resp.result && resp.result.responses) { - for(var i = 0; i < resp.result.responses.length; ++i) { - (function(response) { - var tab = document.createElement('a'), - tabHeader = document.createElement('div'), - tabServer = document.createElement('div'); + if(resp.result && resp.result.responses) + for(const response of resp.result.responses) { + const tab = document.createElement('a'), + tabHeader = document.createElement('div'), + tabServer = document.createElement('div'); - tab.href = 'javascript:;'; - tab.className = 'whois-result-tab'; - if(i === 0) tab.className += ' whois-result-tab-active'; + tab.href = 'javascript:;'; + tab.className = 'whois-result-tab'; + if(count === 0) tab.className += ' whois-result-tab-active'; - tab.onclick = function() { - var active = document.querySelector('.whois-result-tab-active'); - if(active) active.classList.remove('whois-result-tab-active'); - tab.classList.add('whois-result-tab-active'); - result.textContent = response.lines.join("\r\n").trim(); - }; + tab.onclick = function() { + const active = $q('.whois-result-tab-active'); + if(active) active.classList.remove('whois-result-tab-active'); + tab.classList.add('whois-result-tab-active'); + result.textContent = response.lines.join("\r\n").trim(); + }; - tabHeader.className = 'whois-result-tab-header'; - tabHeader.textContent = i === 0 ? 'Result' : ('Hop #' + (resp.result.responses.length - i).toString()); + tabHeader.className = 'whois-result-tab-header'; + tabHeader.textContent = count === 0 ? 'Result' : ('Hop #' + (resp.result.responses.length - count).toString()); - tabServer.className = 'whois-result-tab-server'; - tabServer.textContent = response.server; - console.log(response); + tabServer.className = 'whois-result-tab-server'; + tabServer.textContent = response.server; - tab.appendChild(tabHeader); - tab.appendChild(tabServer); - tabs.appendChild(tab); - })(resp.result.responses[i]); + tab.appendChild(tabHeader); + tab.appendChild(tabServer); + tabs.appendChild(tab); + + ++count; } - } if(tabs.firstChild) tabs.firstChild.click(); unlock(); }); - xhr.open('GET', '/whois/lookup?target=' + encodeURIComponent(target)); - xhr.send(); } - var readHash = function() { + const readHash = function() { if(location.hash.length < 2) { result.textContent = 'Enter a domain or IP address!'; return; } - var target = decodeURIComponent(location.hash.substring(1)); + const target = decodeURIComponent(location.hash.substring(1)); if(input.value !== target) input.value = target; lookup(target); diff --git a/public/index.php b/public/index.php index 2a31e92..ad0e293 100644 --- a/public/index.php +++ b/public/index.php @@ -18,4 +18,9 @@ set_exception_handler(function(\Throwable $ex) { exit; }); +$makai->startCSRFP( + $cfg['csrfp'] ?? 'meow', + (string)filter_input(INPUT_SERVER, 'REMOTE_ADDR') +); + $makai->createRouting()->dispatch(); diff --git a/src/MakaiContext.php b/src/MakaiContext.php index 24e6039..5426f11 100644 --- a/src/MakaiContext.php +++ b/src/MakaiContext.php @@ -3,11 +3,13 @@ namespace Makai; use Index\Environment; use Index\Data\IDbConnection; +use Index\Security\CSRFP; use Sasae\SasaeEnvironment; final class MakaiContext { private IDbConnection $dbConn; private SasaeEnvironment $templating; + private CSRFP $csrfp; private SiteInfo $siteInfo; @@ -26,10 +28,6 @@ final class MakaiContext { $this->sshKeys = new SSHKeys\SSHKeys($dbConn); } - public function getDatabase(): IDbConnection { - return $this->dbConn; - } - public function getSiteInfo(): SiteInfo { return $this->siteInfo; } @@ -46,6 +44,10 @@ final class MakaiContext { return $this->sshKeys; } + public function getDatabase(): IDbConnection { + return $this->dbConn; + } + public function getTemplating(): SasaeEnvironment { return $this->templating; } @@ -58,12 +60,21 @@ final class MakaiContext { cache: $isDebug ? null : ['Makai', GitInfo::hash(true)], debug: $isDebug, ); + $this->templating->addFunction('csrfp_token', fn() => $this->getCSRFP()->createToken()); $this->templating->addGlobal('globals', [ 'siteInfo' => $this->siteInfo, 'assetsInfo' => AssetsInfo::fromCurrent(), ]); } + public function getCSRFP(): CSRFP { + return $this->csrfp; + } + + public function startCSRFP(string $secretKey, string $identity): void { + $this->csrfp = new CSRFP($secretKey, $identity); + } + public function createRouting(): RoutingContext { $routingCtx = new RoutingContext($this->templating); $routingCtx->registerDefaultErrorPages(); @@ -74,7 +85,7 @@ final class MakaiContext { $routingCtx->register(new AssetsRoutes($this->siteInfo)); $routingCtx->register(new NowListeningRoutes($this->templating)); - $routingCtx->register(new Whois\WhoisRoutes($this->templating)); + $routingCtx->register(new Whois\WhoisRoutes($this->templating, fn() => $this->csrfp)); $routingCtx->register(new SSHKeys\SSHKeysRoutes($this->sshKeys)); diff --git a/src/Whois/WhoisRoutes.php b/src/Whois/WhoisRoutes.php index 3f5d302..8ffd228 100644 --- a/src/Whois/WhoisRoutes.php +++ b/src/Whois/WhoisRoutes.php @@ -7,7 +7,8 @@ use Sasae\SasaeEnvironment; class WhoisRoutes extends RouteHandler { public function __construct( - private SasaeEnvironment $templating + private SasaeEnvironment $templating, + private \Closure $csrfp, ) {} #[Route('GET', '/whois')] @@ -15,9 +16,23 @@ class WhoisRoutes extends RouteHandler { return $this->templating->render('whois/index'); } - #[Route('GET', '/whois/lookup')] - public function getLookup($response, $request): array { - $target = trim((string)$request->getParam('target')); + #[Route('POST', '/whois/lookup')] + public function postLookup($response, $request): array { + if(!$request->isFormContent()) + return 400; + + $content = $request->getContent(); + $csrfp = ($this->csrfp)(); + + if(!$csrfp->verifyToken((string)$content->getParam('_csrfp'))) + return [ + 'error' => true, + 'text' => 'Could not validate request, please refresh the page.', + ]; + + $response->setHeader('X-CSRFP', $csrfp->createToken()); + + $target = trim((string)$content->getParam('target')); if(empty($target)) return [ 'error' => true, diff --git a/templates/master.twig b/templates/master.twig index aa94922..28e6bb8 100644 --- a/templates/master.twig +++ b/templates/master.twig @@ -4,6 +4,7 @@ {% if master_title is defined and master_title is not empty %}