aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMatt Strapp <matt@mattstrapp.net>2022-02-14 20:08:16 -0600
committerMatt Strapp <matt@mattstrapp.net>2022-02-14 20:25:15 -0600
commit26b391452a7a629fb28d85caac3f8eba95cc1ddd (patch)
treea3220d10cdee3aa125c02648ab8e38d64380ae5c
parentUpdate some dependencies (diff)
downloadee4511w-web-26b391452a7a629fb28d85caac3f8eba95cc1ddd.tar
ee4511w-web-26b391452a7a629fb28d85caac3f8eba95cc1ddd.tar.gz
ee4511w-web-26b391452a7a629fb28d85caac3f8eba95cc1ddd.tar.bz2
ee4511w-web-26b391452a7a629fb28d85caac3f8eba95cc1ddd.tar.lz
ee4511w-web-26b391452a7a629fb28d85caac3f8eba95cc1ddd.tar.xz
ee4511w-web-26b391452a7a629fb28d85caac3f8eba95cc1ddd.tar.zst
ee4511w-web-26b391452a7a629fb28d85caac3f8eba95cc1ddd.zip
Make responses more clear
Signed-off-by: Matt Strapp <matt@mattstrapp.net>
-rw-r--r--package.json1
-rw-r--r--src/public/css/style.css5
-rw-r--r--src/public/js/form.js13
-rw-r--r--src/public/lib/prism.css3
-rw-r--r--src/public/lib/prism.js4
-rw-r--r--src/routes/api.ts64
-rw-r--r--src/views/pages/index.ejs6
-rw-r--r--yarn.lock5
8 files changed, 81 insertions, 20 deletions
diff --git a/package.json b/package.json
index 57b1c43..9739358 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
"express-fileupload": "^1.3.1",
"express-rate-limit": "^6.2.1",
"helmet": "^5.0.2",
+ "loglevel": "^1.8.0",
"shell-quote": "^1.7.3"
},
"devDependencies": {
diff --git a/src/public/css/style.css b/src/public/css/style.css
index d48e8b9..b1651df 100644
--- a/src/public/css/style.css
+++ b/src/public/css/style.css
@@ -117,7 +117,10 @@ a {
text-decoration: none;
}
+h2 {
+ font-size: larger;
+}
+
.error {
color: red;
- font-size: larger;
} \ No newline at end of file
diff --git a/src/public/js/form.js b/src/public/js/form.js
index f58a27f..581d5a3 100644
--- a/src/public/js/form.js
+++ b/src/public/js/form.js
@@ -4,10 +4,12 @@ window.onload = function () {
};
// File submit AJAX request
+// After successful upload, actuate the file by calling actuate() on the successfully uploaded file
document.getElementById('upload').onsubmit = function () {
- // Reset error message
+ // Reset error message and success message
document.getElementById('upload-err').innerText = '';
document.getElementById('actuate-err').innerText = '';
+ document.getElementById('upload-response').innerText = '';
// Make AJAX request
let xhr = new XMLHttpRequest();
xhr.open('POST', '/api/v1/upload');
@@ -16,7 +18,9 @@ document.getElementById('upload').onsubmit = function () {
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
let response = JSON.parse(xhr.responseText);
- if (xhr.status === 200) {
+ if (xhr.status === 201) {
+ // Display upload success message to the user
+ document.getElementById('upload-response').innerText = response.msg;
actuate(response);
} else {
// Display upload error message to the user
@@ -27,9 +31,14 @@ document.getElementById('upload').onsubmit = function () {
}
}
};
+ document.getElementById('upload').reset();
return false;
};
+// Actuate button AJAX request
+// Should always be called after upload
+// Implies that upload has been successful since it relies on the upload response
+//
function actuate(file) {
let xhr = new XMLHttpRequest();
xhr.open('POST', '/api/v1/actuate');
diff --git a/src/public/lib/prism.css b/src/public/lib/prism.css
deleted file mode 100644
index 4fb0a7c..0000000
--- a/src/public/lib/prism.css
+++ /dev/null
@@ -1,3 +0,0 @@
-/* PrismJS 1.26.0
-https://prismjs.com/download.html#themes=prism-okaidia&languages=python */
-code[class*=language-],pre[class*=language-]{color:#f8f8f2;background:0 0;text-shadow:0 1px rgba(0,0,0,.3);font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre)>code[class*=language-],pre[class*=language-]{background:#272822}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#8292a2}.token.punctuation{color:#f8f8f2}.token.namespace{opacity:.7}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#f92672}.token.boolean,.token.number{color:#ae81ff}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#a6e22e}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#e6db74}.token.keyword{color:#66d9ef}.token.important,.token.regex{color:#fd971f}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
diff --git a/src/public/lib/prism.js b/src/public/lib/prism.js
deleted file mode 100644
index 958c4c6..0000000
--- a/src/public/lib/prism.js
+++ /dev/null
@@ -1,4 +0,0 @@
-/* PrismJS 1.26.0
-https://prismjs.com/download.html#themes=prism-okaidia&languages=python */
-var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(u){var t=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,n=0,e={},M={manual:u.Prism&&u.Prism.manual,disableWorkerMessageHandler:u.Prism&&u.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof W?new W(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/\u00a0/g," ")},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++n}),e.__id},clone:function t(e,r){var a,n;switch(r=r||{},M.util.type(e)){case"Object":if(n=M.util.objId(e),r[n])return r[n];for(var i in a={},r[n]=a,e)e.hasOwnProperty(i)&&(a[i]=t(e[i],r));return a;case"Array":return n=M.util.objId(e),r[n]?r[n]:(a=[],r[n]=a,e.forEach(function(e,n){a[n]=t(e,r)}),a);default:return e}},getLanguage:function(e){for(;e;){var n=t.exec(e.className);if(n)return n[1].toLowerCase();e=e.parentElement}return"none"},setLanguage:function(e,n){e.className=e.className.replace(RegExp(t,"gi"),""),e.classList.add("language-"+n)},currentScript:function(){if("undefined"==typeof document)return null;if("currentScript"in document)return document.currentScript;try{throw new Error}catch(e){var n=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(e.stack)||[])[1];if(n){var t=document.getElementsByTagName("script");for(var r in t)if(t[r].src==n)return t[r]}return null}},isActive:function(e,n,t){for(var r="no-"+n;e;){var a=e.classList;if(a.contains(n))return!0;if(a.contains(r))return!1;e=e.parentElement}return!!t}},languages:{plain:e,plaintext:e,text:e,txt:e,extend:function(e,n){var t=M.util.clone(M.languages[e]);for(var r in n)t[r]=n[r];return t},insertBefore:function(t,e,n,r){var a=(r=r||M.languages)[t],i={};for(var l in a)if(a.hasOwnProperty(l)){if(l==e)for(var o in n)n.hasOwnProperty(o)&&(i[o]=n[o]);n.hasOwnProperty(l)||(i[l]=a[l])}var s=r[t];return r[t]=i,M.languages.DFS(M.languages,function(e,n){n===s&&e!=t&&(this[e]=i)}),i},DFS:function e(n,t,r,a){a=a||{};var i=M.util.objId;for(var l in n)if(n.hasOwnProperty(l)){t.call(n,l,n[l],r||l);var o=n[l],s=M.util.type(o);"Object"!==s||a[i(o)]?"Array"!==s||a[i(o)]||(a[i(o)]=!0,e(o,t,l,a)):(a[i(o)]=!0,e(o,t,null,a))}}},plugins:{},highlightAll:function(e,n){M.highlightAllUnder(document,e,n)},highlightAllUnder:function(e,n,t){var r={callback:t,container:e,selector:'code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'};M.hooks.run("before-highlightall",r),r.elements=Array.prototype.slice.apply(r.container.querySelectorAll(r.selector)),M.hooks.run("before-all-elements-highlight",r);for(var a,i=0;a=r.elements[i++];)M.highlightElement(a,!0===n,r.callback)},highlightElement:function(e,n,t){var r=M.util.getLanguage(e),a=M.languages[r];M.util.setLanguage(e,r);var i=e.parentElement;i&&"pre"===i.nodeName.toLowerCase()&&M.util.setLanguage(i,r);var l={element:e,language:r,grammar:a,code:e.textContent};function o(e){l.highlightedCode=e,M.hooks.run("before-insert",l),l.element.innerHTML=l.highlightedCode,M.hooks.run("after-highlight",l),M.hooks.run("complete",l),t&&t.call(l.element)}if(M.hooks.run("before-sanity-check",l),(i=l.element.parentElement)&&"pre"===i.nodeName.toLowerCase()&&!i.hasAttribute("tabindex")&&i.setAttribute("tabindex","0"),!l.code)return M.hooks.run("complete",l),void(t&&t.call(l.element));if(M.hooks.run("before-highlight",l),l.grammar)if(n&&u.Worker){var s=new Worker(M.filename);s.onmessage=function(e){o(e.data)},s.postMessage(JSON.stringify({language:l.language,code:l.code,immediateClose:!0}))}else o(M.highlight(l.code,l.grammar,l.language));else o(M.util.encode(l.code))},highlight:function(e,n,t){var r={code:e,grammar:n,language:t};if(M.hooks.run("before-tokenize",r),!r.grammar)throw new Error('The language "'+r.language+'" has no grammar.');return r.tokens=M.tokenize(r.code,r.grammar),M.hooks.run("after-tokenize",r),W.stringify(M.util.encode(r.tokens),r.language)},tokenize:function(e,n){var t=n.rest;if(t){for(var r in t)n[r]=t[r];delete n.rest}var a=new i;return I(a,a.head,e),function e(n,t,r,a,i,l){for(var o in r)if(r.hasOwnProperty(o)&&r[o]){var s=r[o];s=Array.isArray(s)?s:[s];for(var u=0;u<s.length;++u){if(l&&l.cause==o+","+u)return;var c=s[u],g=c.inside,f=!!c.lookbehind,h=!!c.greedy,d=c.alias;if(h&&!c.pattern.global){var v=c.pattern.toString().match(/[imsuy]*$/)[0];c.pattern=RegExp(c.pattern.source,v+"g")}for(var p=c.pattern||c,m=a.next,y=i;m!==t.tail&&!(l&&y>=l.reach);y+=m.value.length,m=m.next){var k=m.value;if(t.length>n.length)return;if(!(k instanceof W)){var x,b=1;if(h){if(!(x=z(p,y,n,f))||x.index>=n.length)break;var w=x.index,A=x.index+x[0].length,E=y;for(E+=m.value.length;E<=w;)m=m.next,E+=m.value.length;if(E-=m.value.length,y=E,m.value instanceof W)continue;for(var P=m;P!==t.tail&&(E<A||"string"==typeof P.value);P=P.next)b++,E+=P.value.length;b--,k=n.slice(y,E),x.index-=y}else if(!(x=z(p,0,k,f)))continue;var w=x.index,L=x[0],S=k.slice(0,w),O=k.slice(w+L.length),j=y+k.length;l&&j>l.reach&&(l.reach=j);var C=m.prev;S&&(C=I(t,C,S),y+=S.length),T(t,C,b);var N=new W(o,g?M.tokenize(L,g):L,d,L);if(m=I(t,C,N),O&&I(t,m,O),1<b){var _={cause:o+","+u,reach:j};e(n,t,r,m.prev,y,_),l&&_.reach>l.reach&&(l.reach=_.reach)}}}}}}(e,a,n,a.head,0),function(e){var n=[],t=e.head.next;for(;t!==e.tail;)n.push(t.value),t=t.next;return n}(a)},hooks:{all:{},add:function(e,n){var t=M.hooks.all;t[e]=t[e]||[],t[e].push(n)},run:function(e,n){var t=M.hooks.all[e];if(t&&t.length)for(var r,a=0;r=t[a++];)r(n)}},Token:W};function W(e,n,t,r){this.type=e,this.content=n,this.alias=t,this.length=0|(r||"").length}function z(e,n,t,r){e.lastIndex=n;var a=e.exec(t);if(a&&r&&a[1]){var i=a[1].length;a.index+=i,a[0]=a[0].slice(i)}return a}function i(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function I(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function T(e,n,t){for(var r=n.next,a=0;a<t&&r!==e.tail;a++)r=r.next;(n.next=r).prev=n,e.length-=a}if(u.Prism=M,W.stringify=function n(e,t){if("string"==typeof e)return e;if(Array.isArray(e)){var r="";return e.forEach(function(e){r+=n(e,t)}),r}var a={type:e.type,content:n(e.content,t),tag:"span",classes:["token",e.type],attributes:{},language:t},i=e.alias;i&&(Array.isArray(i)?Array.prototype.push.apply(a.classes,i):a.classes.push(i)),M.hooks.run("wrap",a);var l="";for(var o in a.attributes)l+=" "+o+'="'+(a.attributes[o]||"").replace(/"/g,"&quot;")+'"';return"<"+a.tag+' class="'+a.classes.join(" ")+'"'+l+">"+a.content+"</"+a.tag+">"},!u.document)return u.addEventListener&&(M.disableWorkerMessageHandler||u.addEventListener("message",function(e){var n=JSON.parse(e.data),t=n.language,r=n.code,a=n.immediateClose;u.postMessage(M.highlight(r,M.languages[t],t)),a&&u.close()},!1)),M;var r=M.util.currentScript();function a(){M.manual||M.highlightAll()}if(r&&(M.filename=r.src,r.hasAttribute("data-manual")&&(M.manual=!0)),!M.manual){var l=document.readyState;"loading"===l||"interactive"===l&&r&&r.defer?document.addEventListener("DOMContentLoaded",a):window.requestAnimationFrame?window.requestAnimationFrame(a):window.setTimeout(a,16)}return M}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism);
-Prism.languages.python={comment:{pattern:/(^|[^\\])#.*/,lookbehind:!0,greedy:!0},"string-interpolation":{pattern:/(?:f|fr|rf)(?:("""|''')[\s\S]*?\1|("|')(?:\\.|(?!\2)[^\\\r\n])*\2)/i,greedy:!0,inside:{interpolation:{pattern:/((?:^|[^{])(?:\{\{)*)\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}]|\{(?!\{)(?:[^{}])+\})+\})+\}/,lookbehind:!0,inside:{"format-spec":{pattern:/(:)[^:(){}]+(?=\}$)/,lookbehind:!0},"conversion-option":{pattern:/![sra](?=[:}]$)/,alias:"punctuation"},rest:null}},string:/[\s\S]+/}},"triple-quoted-string":{pattern:/(?:[rub]|br|rb)?("""|''')[\s\S]*?\1/i,greedy:!0,alias:"string"},string:{pattern:/(?:[rub]|br|rb)?("|')(?:\\.|(?!\1)[^\\\r\n])*\1/i,greedy:!0},function:{pattern:/((?:^|\s)def[ \t]+)[a-zA-Z_]\w*(?=\s*\()/g,lookbehind:!0},"class-name":{pattern:/(\bclass\s+)\w+/i,lookbehind:!0},decorator:{pattern:/(^[\t ]*)@\w+(?:\.\w+)*/m,lookbehind:!0,alias:["annotation","punctuation"],inside:{punctuation:/\./}},keyword:/\b(?:_(?=\s*:)|and|as|assert|async|await|break|case|class|continue|def|del|elif|else|except|exec|finally|for|from|global|if|import|in|is|lambda|match|nonlocal|not|or|pass|print|raise|return|try|while|with|yield)\b/,builtin:/\b(?:__import__|abs|all|any|apply|ascii|basestring|bin|bool|buffer|bytearray|bytes|callable|chr|classmethod|cmp|coerce|compile|complex|delattr|dict|dir|divmod|enumerate|eval|execfile|file|filter|float|format|frozenset|getattr|globals|hasattr|hash|help|hex|id|input|int|intern|isinstance|issubclass|iter|len|list|locals|long|map|max|memoryview|min|next|object|oct|open|ord|pow|property|range|raw_input|reduce|reload|repr|reversed|round|set|setattr|slice|sorted|staticmethod|str|sum|super|tuple|type|unichr|unicode|vars|xrange|zip)\b/,boolean:/\b(?:False|None|True)\b/,number:/\b0(?:b(?:_?[01])+|o(?:_?[0-7])+|x(?:_?[a-f0-9])+)\b|(?:\b\d+(?:_\d+)*(?:\.(?:\d+(?:_\d+)*)?)?|\B\.\d+(?:_\d+)*)(?:e[+-]?\d+(?:_\d+)*)?j?(?!\w)/i,operator:/[-+%=]=?|!=|:=|\*\*?=?|\/\/?=?|<[<=>]?|>[=>]?|[&|^~]/,punctuation:/[{}[\];(),.:]/},Prism.languages.python["string-interpolation"].inside.interpolation.inside.rest=Prism.languages.python,Prism.languages.py=Prism.languages.python;
diff --git a/src/routes/api.ts b/src/routes/api.ts
index bae30b0..015bd45 100644
--- a/src/routes/api.ts
+++ b/src/routes/api.ts
@@ -1,9 +1,12 @@
import express, { Request, Response } from 'express';
+// Middleware for security
import csurf from 'csurf';
import cookieParser from 'cookie-parser';
import fileUpload, { UploadedFile } from 'express-fileupload';
import rateLimit from 'express-rate-limit';
+// For executing the python scripts
import { access, stat } from 'fs/promises';
+import { Stats } from 'fs';
import { quote } from 'shell-quote';
import { spawn } from 'child_process';
@@ -32,7 +35,28 @@ api.use(rateLimiter);
api.use(cookieParser());
const csrf = csurf({ cookie: true });
+// Use JSON parser for API requests and responses
api.use(express.json());
+
+/*
+ Upload a file to the server
+ POST /api/v1/upload
+ Parameters:
+ files: The file to upload
+ Returns:
+ 201:
+ {
+ "message": "File uploaded successfully",
+ "file": {
+ "name": "file.py",
+ "path": "/tmp/file-538126",
+ }
+ }
+ 400 when there is no file
+ 413 when the file is too large
+ 415 when the file's MIME type is not text/x-python
+ 500 for any other errors
+*/
api.route('/upload')
.post(csrf, (req: Request, res: Response) => {
try {
@@ -50,7 +74,7 @@ api.route('/upload')
if (file.mimetype !== 'text/x-python')
return res.status(415).json({ error: 'File uploaded was not a Python file.' });
- res.status(200).json({ file: file.name, path: file.tempFilePath, csrf: req.csrfToken() });
+ res.status(201).json({ file: file.name, path: file.tempFilePath, msg: 'File uploaded successfully.', csrf: req.csrfToken() });
} catch (err) {
// Generic error handler
res.status(500).json({ error: 'An unknown error occurred while uploading the file.', error_msg: err });
@@ -63,9 +87,28 @@ api.route('/upload')
});
/*
- This route is probably a complete security hole. It allows anyone with a login cookie access to run arbitrary Python code on the server.
+ Actuate the pendulum
+ POST /api/v1/actuate
+ Parameters:
+ name: The name of the file to run, currently unused
+ path: The path to the uploaded file on the server, passed in from /api/v1/upload on the website
+ Returns:
+ 200:
+ {
+ "stdout": "Hello from Python!\n",
+ }
+ 400 for when the file passed in is not a regular file
+ 403 when the file is not accessible
+ 500:
+ {
+ "error": "Program exited with error code 1.",
+ "error_msg": "NameError: name 'sleep' is not defined",
+ }
+
+ This route is probably a complete security nightmare. It allows anyone to run arbitrary Python code on the server.
+
+ Minimizing PE vectors like running this as an extremely low privilege user is a must.
- Minimizing PE vectors like running this as a low privilege user is a must.
*/
api.route('/actuate')
.post(csrf, async (req: Request, res: Response) => {
@@ -77,7 +120,7 @@ api.route('/actuate')
}
- const stats = await stat(req.body.path);
+ const stats: Stats = await stat(req.body.path);
// Make sure the file being requested to run is a regular file
if (!stats.isFile())
return res.status(400).json({ error: 'File is not a regular file.' });
@@ -88,12 +131,15 @@ api.route('/actuate')
const escaped = quote( [ req.body.path ] );
// Run the code
/*
- TODO: MAKE THIS MORE SECURE
+ TODO:
+ - Potentially add the limiter to one-per-person here
+ - Add a timeout
+ - Communicate to the machine to give up (the user as well), maybe just kill the process?
+ - Make this more secure
+ - HOW?
*/
let output = '';
- // NOT PORTABLE: ASSUMES PYTHON 3 IS THERE AS WELL AS ON UNIX
- // TODO: MAKE PORTABLE
- const actuation = spawn('/usr/bin/python', escaped.split(' '));
+ const actuation = spawn('python', escaped.split(' '));
actuation.stdout.on('data', (data: Buffer) => {
output += data.toString();
});
@@ -102,7 +148,7 @@ api.route('/actuate')
});
actuation.on('close', (code: number) => {
if (code !== 0)
- res.status(500).json({ error: 'An unknown error occurred while running the file.', error_msg: output });
+ res.status(500).json({ error: `Program exited with exit code ${code}`, error_msg: output });
res.status(200).json({ stdout: output });
});
})
diff --git a/src/views/pages/index.ejs b/src/views/pages/index.ejs
index 81624f6..357baae 100644
--- a/src/views/pages/index.ejs
+++ b/src/views/pages/index.ejs
@@ -30,7 +30,11 @@
<label id="Start">Start pendulum: </label>
<input type="submit" id="actuate_but" value="Actuate!" />
</form>
- </span>
+ <h2>
+ <div id="upload-response"></div>
+ </h2>
+
+ </div>
</body>
diff --git a/yarn.lock b/yarn.lock
index f784039..f3742cc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1663,6 +1663,11 @@ lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+loglevel@^1.8.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.0.tgz#e7ec73a57e1e7b419cb6c6ac06bf050b67356114"
+ integrity sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==
+
lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"