原文:https://arkakapimag.com/blog/why_you_shouldnt_store_sensitive_data_in_javascript_files.html?q=reddit
众所周知,将敏感数据存储在JavaScript文件中不仅不是一种好习惯,甚至是一种相当危险的做法。实际上,其中的原因也很简单,下面我们举例说明。假设我们动态生成了一个包含用户API密钥的JavaScript文件。
apiCall = function(type, api_key, data) { ... }
var api_key = '1391f6bd2f6fe8dcafb847e0615e5b29'
var profileInfo = apiCall('getProfile', api_key, 'all')
当我们在全局作用域中创建一个变量后,就像上面的例子一样,任何包含了该脚本文件的网站就都可以使用这个变量了。
人们为什么要干这么危险的事呢?
开发人员将敏感信息嵌入JavaScript文件的原因非常多。对于缺乏经验的开发人员来说,当他们需要将服务器端存储或生成的信息传递给客户端代码的时候,这可能是最显而易见的方法。此外,这还可以将一些额外的请求保存到服务器中。然而,这里经常被忽视的一个方面是浏览器扩展。有时,为了使用完全相同的窗口对象,需要直接将脚本标记注入DOM,因为仅仅使用内容脚本是不可能实现这个目的的。
有保护变量的方法吗?
上面,我们已经讨论了全局作用域。对于浏览器中的JavaScript代码来说,全局变量实际上就是窗口对象的属性。然而,在ECMA Script 5中,还有另外一种作用域,即函数作用域。这意味着,如果我们使用var关键字在函数中声明一个变量,我们就无法在全局作用域中使用该变量。后来,ECMA Script 6又引入了另外一种作用域,即块作用域,以及关键字const和let。
这两个关键字都用于在块作用域中声明变量,不过,对于使用const创建的变量来说,则不允许对其重新赋值。如果我们声明变量时没有使用上述任何一种关键字,或者如果我们在函数外部使用var类型的变量,我们就会创建一个全局变量,实际上我们很少想要这么做。
"use strict";
防止意外创建全局变量的有效方法是激活严格模式。为了激活该模式,只需在文件或函数的开头添加字符串“use strict”即可。这样的话,就会禁止使用尚未声明的变量。
"use strict";
var test1 = 'arka' // works
test2 = 'kapı' // Reference Error
我们可以将其与所谓的立即调用函数表达式(简称IIFE,发音为iffy)结合使用。IIFE可用于创建函数作用域,但它们会立即执行函数体。下面,让我们举例说明。
(function() {
"use strict";
//variable declared within function scope
var privateVar = 'Secret value';
})()
console.log(privateVar) // Reference Error
乍一看,这好像是一种创建变量的有效方法,因为这些变量的内容无法在其作用域之外读取。但是,千万不要上当。虽然IIFE是避免污染全局命名空间的好方法,但是用来保护其内容的话,并不完全适合。
从私有变量中读取敏感数据
如果想要保护私有变量的内容的私密性的话,几乎是不可能的。之所以这么说,原因是多方面的,接下来,我们将会对部分原因进行分析。当然,我们不会面面俱到,相反,我们只是为了让大家明白为什么永远不应该在JavaScript文件中保存敏感数据。
覆盖原生函数
之所以说在JavaScript文件中保存敏感数据是非常危险的做法,最明显的原因是,我们实际上希望使用变量的值来执行某项任务。在我们的第一个示例中,我们需要使用密钥向服务器发送请求。因此,我们需要通过网络以明文形式发送它。目前,在JavaScript中能够做到这一点的方法不是很多。下面,假设我们的代码使用fetch()函数。
window.fetch = (url, options) => {
console.log(`URL: ${url}, data: ${options.body}`);
};
// EXTERNAL SCRIPT START
(function(){
"use strict";
var api_key = "1391f6bd2f6fe8dcafb847e0615e5b29"
fetch('/api/v1/getusers', {
method: "POST",
body: "api_key=" + api_key
});
})()
// EXTERNAL SCRIPT END
如您所见,我们可以直接覆盖fetch函数,并通过这种方式来窃取API密钥。唯一的先决条件是,我们需要能够在自己的脚本块之后包含外部脚本。在这个例子中,我们只是将其注销,不过,我们也可以将其发送给自己的服务器。
定义Setter和Getter
私有变量不仅可以包含字符串,而且还可以包含对象或数组。对象可以带有不同的属性,在大多数情况下,我们可以为其赋值,并能读取相应的值。但JavaScript提供了一个非常有趣的功能。如果在对象上设置或访问属性时,我们还可以执行函数。这一点适用于__defineSetter__
和__defineGetter__
函数。如果我们将__defineSetter__
函数应用于Object构造函数的原型,我们就可以有效地记录分配给具有特定名称的属性的每个值。
Object.prototype.__defineSetter__('api_key', function(value){
console.log(value);
return this._api_key = value;
});
Object.prototype.__defineGetter__('api_key', function(){
return this._api_key;
});
// EXTERNAL SCRIPT START
(function(){
"use strict"
let options = {}
options.api_key = "1391f6bd2f6fe8dcafb847e0615e5b29"
options.name = "Alice"
options.endpoint = "get_user_data"
anotherAPICall(options);
})()
// EXTERNAL SCRIPT END
如果代码将属性分配给了包含API密钥的对象,我们就能够使用我们的setter来轻松访问它了。另一方面,getter将确保其余代码可以正常工作。虽然这些并不是绝对必要的,但有时可能会带来很大的帮助。
自定义迭代器
在考察了使用setter/getter传递给本机函数和对象的字符串之后,接下来,就要开始考察相关的数组了。如果代码使用for ... of循环来遍历数组的话,可以根据Array构造函数的原型来定义一个定制的迭代器。这样的话,就能在访问数组的内容的同时,仍然可以维护好相应的操作码了。
Array.prototype[Symbol.iterator] = function() {
let arr = this;
let index = 0;
console.log(arr)
return {
next: function() {
return {
value: arr[index++],
done: index > arr.length
}
}
}
};
// EXTERNAL SCRIPT START
(function() {
let secretArray = ["this", "contains", "an", "API", "key"];
for (let element of secretArray) {
doSomething(element);
}
})()
// EXTERNAL SCRIPT END
在本文中,我们不会介绍迭代器的概念,因为这已经超出了本文讨论的范围。实际上,我们只要知道可以从自定义Symbol.iterator方法中访问整个数组,从而窃取其中本应保密的值就行了。
小结
在本文中,我们为读者介绍了攻击者从脚本文件中窃取敏感数据的一种方法,当然,这里只是其中一种,除此之外,还有许多方式,这里就不再一一介绍了。面对这些攻击,即使是IIFE、严格模式和函数/块作用域中的声明变量,也是毫无还手之力。我们的建议是,从服务器动态获取敏感数据,而不是将其写入JavaScript文件。在大多数(即使不是全部的话)情况下,这都是一种明智的选择;并且,这种方式还更易于维护。