Java编码安全

本文根据Java安全开发Checklist来展开

数据校验

规则1.1:校验跨信任边界传递的不可信数据

不可信数据:用户、网络连接等数据源

数据入口:

  1. 终端计算机;
  2. 互联网出入口;
  3. 广域网出入口;
  4. 公司对外发布服务的DMZ服务器;
  5. VPN和类似远程连接设备;

信任边界:根据威胁建模划分的信任边界,如web应用的服务器。

  1. 黑名单策略-拒绝已知不好的数据
  1. public String removeJavaScript(String input){
  2. Pattern p=Pattern.compile(regex:"javascript",Pattern.CASE_INSENSITIVE);
  3. Matcher m=p.matcher(input);
  4. return (!m.matches())?input:"";
  5. }
  1. 白名单策略-接受已知好的数据(任何时候,尽可能使用“白名单”的策略)
  1. if(Pattern.matches(regex:"^[0-9A-Za-z_]+$",name)){
  2. throw new IllegalArgumentException("Invalid name");
  3. }
  1. 黑名单净化-剔除或者转换某些字符(例如,删除引号、转换成HTML实体)

  2. 白名单净化-对数据中任何不属于某个已验证的、合法字符列表的字符进行删除、编码、或者替换,然后再使用这些净化后的数据

规则1.2:禁止直接使用不可信数据来拼接SQL语句

SQL注入是指原始SQL查询被动态更改成一个与程序预期完全不同的查询。执行这样一个更改后的查询可能导致信息泄露或者数据被篡改。防止SQL注入的方式主要可以分为三类:

  1. 使用参数化查询(推荐使用);
  2. 对不可信数据进行校验;
  3. 预编译处理。

缺陷代码实例

  1. Statement stmt = connect.createStatement();
  2. String sql = "SELECT * FROM cg_user WHERE userId"+ userId + "AND name LIKE" +name";
  3. ResultSet rs =stmt.executeUpdate(sql);

入参name的值为

or ‘1’=‘1’

那么SQL语句就是永真的,将会返回所有数据

或者入参为

[‘;drop table cg_user ;]

经过SQL拼接传入后端就变成了

  1. SELECT * FROM cg_user WHERE userId='' AND name LIKE ''; drop table cg_user;

这里看到是一个没有经过任何处理的代码,她完全信任用户的输入,将username 和itemname直接拼接到数据库的查询语句中攻击者在构造输入时只需要将单引号闭合,然后传入永真式,就可以绕过密码直接登录.

预编译

使用PreparedStatement预编译SQL,传递给PreparedStatement对象的参数可以被强制进行类型转换,确保在插入或查询数据时与底层的数据库格式匹配

  1. PreparedStatement preparedStatement = connect.prepareStatement("SELECT * FROM cg_user WHERE userId= ? AND name LIKE ?");
  2. preparedStatement .setInt(1, '');
  3. preparedStatement .setString(2, "; drop table cg_user");
  4. preparedStatement .executeUpdate();

转化为数据库sql语句即为

  1. SELECT * FROM cg_user WHERE userId='' AND name LIKE '; drop table cg_user' ;

${} VS #{}

  美刀符号是实现动态传参,不能防止sql 注入,他是拼接符,会将括号里面的参数进行直接替换,不含占位符,输入之后会被解析为jndi请求,实现了jndi注入,这也是Log4j2的成因。MyBatis创建预处理语句参数,通过 JDBC,这样的一个参数在 SQL 中会由一个 “?” 来标识,并被传递到一个新的预处理语句中,就像这样。

默认情况下,使用#{}格式的语法会导致 MyBatis 创建预处理语句属性并安全地设置值(比如?)。这样做更安全,更迅速,通常也是首选做法,MyBatis 会创建PreparedStatement 参数占位符,并通过占位符安全地设置参数

应避免外部输入未经过滤直接拼接到SQL语句中,或者通过Mybatis中的美刀{}传入SQL语句(即使使用PreparedStatement,SQL语句直接拼接外部输入也同样有风险。例如Mybatis中部分参数通过${}传入SQL语句后实际执行时调用的是PreparedStatement.execute(),同样存在注入风险)。

  1. 美刀{}方式无法防止Sql注入。
  2. 美刀{}方式一般用于传入数据库对象,例如传入表名.
  3. 一般能用井号{}的就别用美刀{}.
  4. 井号{}方式能够很大程度防止sql注入。
  5. 井号相当于对数据 加上 双引号,美刀相当于直接显示数据
  6. 井号将传入的数据都当成一个字符串,会对自动传入的数据加一个双引号。
  7. 美刀将传入的数据直接显示生成在sql中。如:order by userid user_id useri​d,如果传入的值是111,那么解析成sql时的值为order by 111, 如果传入的值是id,则解析成的sql为order by id.
  8. 井号{} 就是编译好SQL语句再取值.
  9. 美刀{} 就是取值以后再去编译SQL语句.

    井号{}最明显的优点就是防止sql注入,这是由于美刀在预编译之前就会被变量替换,这会存在sql注入问题,美刀{ } 仅仅为一个纯碎的 string 替换,在动态 SQL 解析阶段将会进行变量替换
  1. <select id="queryMaxDataver" parameterType="String" resultType="String">
  2. select max(data_version) as mvn from sys_data_log
  3. where data_table=#{tableName} and data_id=#{dateId}
  4. </select>

SQL语句中拼接的参数进行编码转义

转义前

  1. String id = "1' or '1'='1' #";
  2. String idEncode = sqlTool.mysqlSanitise(id);
  3. String query = "SELECT NAME FROM users WHERE id = '" + idEncode + "'";

转义后

  1. SELECT NAME FROM users WHERE id = '1' or '1'='1' #';
  2. SELECT NAME FROM users WHERE id = '1\' or \'1\'\=\'1\' \#';

可以看到调用了mysqlSanitise之后,单引号被转义

其中的mysqlSanitise(id)具有对单引号进行转义的作用,因为实现SQL注入就是进行了参数两端双引号的闭合,使得可以注入攻击代码。该函数还可以过滤其他特殊字符。

JDK源代码

  1. /**
  2. * @Description: 过滤Mysql sql语句中的特殊字符,暂不支持数据库采用gbk编码
  3. * @Param: desc 反序列化的类
  4. * @return: Class 类对象
  5. */
  6. public String mysqlSanitise(String input){
  7. return super.sqlSanitise(mysqlEncoder, input);
  8. }
  9. /**
  10. * @Description: 过滤表名、列名的特殊字符,暂不支持数据库采用gbk编码
  11. * @Param: codec 数据库类型
  12. * @return: String 过滤后语句
  13. */
  14. public String mysqlSanitise(String input, boolean isColumn){
  15. return super.sqlSanitise(mysqlEncoder, input, isColumn);
  16. }

规则1.4:禁止直接使用不可信数据来记录数据

如果在记录日志中包含未经校验的不可信数据,则可能导致日志注入漏洞。恶意的用户会插入伪造的日志数据,从而让系统管理员误以为这些日志数据是由系统记录的。这个和sql注入类似,也是一种注入攻击。

我们看到日志记录的代码中,直接传入username拼接到日志当中,并没有进行过滤操作,但是当用户构造username时用回车和换行符,就会给人产生误解。



例如,一个用户可能通过输入一个回车符和一个换行符(CRLF)序列来将一条合法日志拆分成两条日志,其中每一条都可能会令人误解。将未经净化的用户输入写入日志还可能会导致向信任边界之外泄露敏感数据,或者导致违反当地法律法规,在日志中写入和存储了某些类型的敏感数据。

如图经过构造后仿佛通过administartor身份成功登陆了。



规则1.6:验证路径前将其标准化

绝对路径名或者相对路径名中可能会包含文件的链接,对文件名标准化可以使得验证文件路径更加容易,同时可以防御目录遍历引起的安全漏洞。

getCanoincalPath就是获取标准路径

大概就是说getCanonicalPath()获得的格式是和系统相关的(Linux和Windows下不一样),在执行过程中会将当前的path转换成absolute path,然后去掉absolute path 内重复的 .和 ..等等。

  1. public static void main(String[] args){
  2. File f=new File(System.getProperty("user.home")+
  3. System.getProperty("file.separator")+args[0]);
  4. String canonicalPath=f.getAbsolutePath();
  5. if(!isInSecureDir(Paths.get(absPath))){
  6. Throw new IllegalArgumentException();
  7. }
  8. if(!validate(absPath)){
  9. Throw new IllegalArgumentException();
  10. }
  11. }
  1. public static void main(String[] args){
  2. File f=new File(System.getProperty("user.home")+
  3. System.getProperty("file.separator")+args[0]);
  4. String canonicalPath=f.getCanoincalPath();
  5. if(!isInSecureDir(Paths.get(absPath))){
  6. Throw new IllegalArgumentException();
  7. }
  8. if(!validate(absPath)){
  9. Throw new IllegalArgumentException();
  10. }
  11. }

规则1.7:安全的从ZipInputStream提取文件

  1. 提取出的文件标准路径落在解压的目标目录之外—跨目录解压攻击
  2. 提取出的文件消耗过多的系统资源--zip压缩炸弹

这是一个缺陷代码,可以看到高亮的这两行,并未对解压的文件名做验证,直接将文件名传递给FileOutputStream构造器。

第二行它也未检查解压文件的资源消耗情况,它允许程序运行到操作完成或者本地资源被耗尽

  1. static final int BUFFER = 512;
  2. public final void unzip(String filename) throws java.io.IOException {
  3. FileInputStream fis = new FileInputStream(filename);
  4. ZipInputStream zis = new ZipInputStream(new BufferedInputStream(fis));
  5. ZipEntry entry;
  6. while((entry = zis.getNextEntry()) != null) {
  7. System.out.println(“extracting: + entry);
  8. int count;
  9. byte data[] = new byte[BUFFER];
  10. FileOutputStream fos = new FileOutputStream(entry.getName());
  11. BufferedOutputStream dest=new BufferedOutputStream(fos,BUFFER)
  12. while((count = zis.read(data,0,BUFFER)) != -1) {
  13. dest.write(data,0,count);
  14. }
  15. dest.flush();
  16. dest.close();
  17. zis.closeEntry();
  18. }
  19. zis.close();
  20. }

zipEntry.getSize()方法

  1. public static final int BUFFER = 512;
  2. public static final int TOOBIG = 0x6400000; // 100M
  3. public final void unzip(String filename) throws java.io.IOException {
  4. FileInputStream fis = new FileInputStream(filename);
  5. ZipInputStream zis = new ZipInputStream(new BufferedInputStream(fis));
  6. ZipEntry entry;
  7. try{
  8. while((entry = zis.getNextEntry()) != null) {
  9. System.out.println(“Extracting: “+ entry);
  10. int count;
  11. byte data[] = new byte[BUFFER];
  12. if(entry.getSize() > TOOBIG) {
  13. throw new IllegalStateException(“file to be unzipped is huge!”);
  14. }
  15. if(entry.getSize() == -1) {
  16. throw new IllegalStateException(“file to be unzipped might be huge”);
  17. }
  18. FileOutputStream fos = new FileOutputStream(entry.getName());
  19. BufferedOutputStram dest = new BufferedOutputStream(fos,BUFFERED);
  20. while((count = zis.read(data,0,BUFFERED)) != -1) {
  21. dest.write(data,0,count);
  22. }
  23. dest.flush();
  24. dest.close(); zip.closeEntry();
  25. } } }

规则1.8:禁止未经验证的用户输入直接输出到HTML界面

用户输入未经过验证直接输出到HTML界面容易导致XSS注入攻击,该攻击方式可以盗取用户cookie信息,严重的可以形成XSS蠕虫攻击漏洞,也可以结合其他的安全漏洞进一步攻击和破坏系统。这种方式没有经过验证,直接把eid输出到html界面中。

  1. String eid=request.getParameter("eid");
  2. eid=StringEscapeUtils.escapeHtml(eid);//insufficient validation
  3. ...
  4. ServletOutputStream out=response.getOutputStream();
  5. out.print("Employee ID:"+eid);
  6. ...
  7. out.close();
  8. ...
  1. 输入过滤:客户端请求参数:包括用户输入,url参数、post参数。

    • 在产品形态上,针对不同输入类型,对输入做变量类型限制。

    • 字符串类型的数据,需要针对<、>、/、’、”、&五个字符进行实体化转义
  2. 输出编码:浏览器解析中html和js编码不一样,以及上下文场景多样,所以对于后台输出的变量,不同的上下文中渲染后端变量,转码不一样。

对于输入的的过滤,主要针对的是用户的输入、url参数,post参数等等,需要对输入的类型做限制,并且对这五个字符转义

对于输出的过滤,需要分情况进行转码,输出编码:浏览器解析中html和js编码不一样,以及上下文场景多样,所以对于后台输出的变量,不同的上下文中渲染后端变量,转码不一样。

常见的就如表所示,如果上下文是HTML,就进行HTML Entity编码

如果是url上,就要对输入规范化,url校验等

规则1.10: 禁止程序数据进行增、删、改、查时对客户端请求的数据过分相信而遗漏对于权限的判定

垂直越权漏洞,也称为权限提升,是一种 “基于URL的访问控制”设计缺陷引起的漏洞,由于Web应用程序没有做权限控制或者仅在菜单上做了权限控制,导致的恶意用户只要猜测其他管理页面的URL,就可以访问或控制其他角色拥有的数据或页面,达到权限提升目的。

水平越权漏洞,是一种“基于数据的访问控制”设计缺陷引起的漏洞,Web应用程序接收到用户请求,修改某条数据时,没有判断数据的所属人,或判断数据所属人时,从用户提交的request参数(用户可控数据)中,获取了数据所属人id,导致恶意攻击者可以通过变换数据ID,或变换所属人id,修改不属于自己的数据。恶意用户可以删除或修改其他人数据。

可以看到在使用RequestMapping处理时,在第一块代码中直接传入value=delete,制定了一个具体值删除,攻击者只要猜测成功就可以垂直越权

  1. @RequestMapping(value=“delete”)
  2. Public String delete(HttpServletRequest request,@RequestParam Long id)
  3. throws Exception {
  4. try{userManager.delete(id);
  5. request.setAttribute(“msg”,”删除用户成功”);
  6. } catch (ServiceException e) {
  7. request.setAttribute(“msg”,”删除用户失败”);
  8. }
  9. return list(request);
  10. }

第二种里面RequestMapping(value="/{[参数]}") 注解可以用来传参,value="/{[参数]}" 有点类似于正则表达式。带上了用户的id

  1. @RequestMapping(value=“/delete/{addrId}”)
  2. Public Object remove(@PathVariable Long addrId) {
  3. Map<String,Object> respMap = new HashMap<String,Object>();
  4. if(WebUtils.isLogged()) {
  5. this.addressService.removeUserAddress(addrId);
  6. respMap.put(“msg”,”删除成功”);
  7. } else {
  8. respMap.put(“msg”,”删除失败”);
  9. }
  10. return respMap;
  11. }

垂直越权漏洞:在调用功能之前,验证当前用户身份是否有权限调用相关功能(推荐使用过滤器,进行统一权限验证)

  1. public void doPost(HttpServeltRequest request,HttpServletResponse response) throws
  2. ServletException,IOException {
  3. if(request.getSession(true).getAttribute(“manager”) == null) {
  4. response.sendRedirect(“noright.html”);
  5. return;
  6. }
  7. StudentManagerServices service = new StudentManagerServices();
  8. requeset.setCharacterEncoding(“UTF-8”);
  9. response.setCharacterEncoding(“UTF-8”);
  10. String action = request.getParameter(“action”);
  11. if(“add”.equals(action)) {
  12. String id = request.getParameter(“studentid”);
  13. String name = request.getParameter(“name”);
  14. }
  15. }

这个修改代码可以看到首先是验证了用户的身份是否有权限,权限不够或者没有就会跳转到noright这个界面上

然后才去执行相关操作

过滤器设计思路

1)清洗URL地址,并提取Api接口名称

2)从session中提取当前登录用户的userid

3)提取当前用户的角色id

4)判断当前用户对应的角色是否有权限访问当前Api接口(检查垂直越权)

5)判断当前登录用户是否对目标对象有操作权限(检查水平越权)

SpringMVC: Spring Security提供了“基于URL的访问控制”

和“基于Method的访问控制”

  1. @RequestMapping(value=“/delete/{addrId}”)
  2. Public Object remove(@PathVariable Long addrId) {
  3. Map<String,Object> respMap = new HashMap<String,Object>();
  4. If(WebUtils.isLogged()) {
  5. this.addressService.removeUserAddress(addrId,WebUtils.getLoggedUserId());
  6. respMap.put(Constants.RESP_STATUS_CODE_KEY,Constants.RESP_STATUS_CODE_SUCCESS);
  7. respMap.put(Constants.MESSAGE,”地址删除成功”);
  8. } else {
  9. respMap.put(Constants.RESP_STATUS_CODE_KEY,Constants.RESP_STATUS_CODE_FATL);
  10. respMap.put(Constants.ERROR,”用户没有登录,删除地址失败”);
  11. }
  12. return respMap;
  13. }

规则1.11:敏感数据在跨信任域之间传递采用签名加密传输

HTTP协议属于明文传输协议,交互过程以及数据传输都没有进行加密,通信双方也没有进行任何认证,通信过程非常容易遭遇劫持、监听、篡改,

HTTPS在HTTP的基础上加入了SSL协议,SSL依靠证书来验证服务器的身份,并为浏览器和服务器直接的通信加密。

敏感数据传输过程中要防止窃取和恶意篡改。使用安全的加密算法加密传输对象可以保护数据。这就是所谓的对对象进行密封。而对密封的对象进行数字签名则可以防止对象被非法篡改,保持其完整性。

keypairgenerator生成密钥对,RSA算法即非对称密钥算法

然后发送方用sha256算法对原文件生成一个签名文件,即32个字节的hash码。 然后用rsa加密算法对此算法加密。

还需要AES 对称加密算法生成最后服务端和客户端通信的密钥

  1. Public static void main(String[] args) throws IOException,GeneralSecurityException,ClassNotFoundException {
  2. SerializableMap<String,Integer> map = buildMap();
  3. KeyPairGenerator kpg = KeyPairGenerator.getInstance(“RSA”);
  4. KeyPair kp = kpg.generateKeyPair();
  5. Signature sig = Signature.getInstance(“SHA256withRSA”);
  6. SignedObject signedMap = new SignedObject(map,kp.getPrivate(),sig);
  7. KeyGenerator generator = KeyGenerator.getInstance(“AES”);
  8. Generator.init(Cipher.ENCRYPT_MODE,key);
  9. SealedObject sealedMap = new SealedObject(signedMap,cipher);
  10. ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(“data”));
  11. Out.writeObject(sealedMap);
  12. Out.close();
  13. //Deserialize map
  14. ObjectInputStream in = new ObjectInputStream(new FileInputStream(“data”));
  15. sealedMap = (SealedObject) in.readObject();
  16. In.close();
  17. //Unseal map cipher = Cipher.getInstance(“AES”);
  18. Cipher.init(Cipher.DECRYPT_MODE,key);
  19. signedMap = (SignedObject) sealedMap.getObject(cipher);
  20. // Verify signature and retrieve map
  21. If(!signedMap.verify(kp.getPublic(),sig)) {
  22. Throw new GeneralSecurityException(“Map failed verification”);}
  23. map = (SerializableMap<String,Integer>) signedMap.getObject();
  24. InspectMap(map);
  25. }

总结:HTTPS要使客户端与服务器端的通信过程得到保证,必须得使用对称加密算法,但是协商对称加密算法的过程,需要使用非对称加密算法来保证安全,然而直接使用非对称加密算法的过程本身也不安全,会有中间人篡改公钥的可能性,所以客户端与服务器不直接使用公钥,而是使用数字证书签发机构(CA)颁发的证书来保证非对称加密过程本身的安全,为了保证证书不被篡改,引入数字签名,客户端使用相同的对称加密算法,来验证证书的真实性,因此解决了客户端与服务器端之间的通信安全问题。

Java编码安全的更多相关文章

  1. java编码过滤器

    1.java编码过滤器的作用: java过滤器能够对目标资源的请求和响应进行截取,过滤信息执行的优先级高于servlet. 2.java过滤器的使用: (1)编写一个普通的java类,实现Filter ...

  2. java中文乱码解决之道(四)-----java编码转换过程

    前面三篇博客侧重介绍字符.编码问题,通过这三篇博客各位博友对各种字符编码有了一个初步的了解,要了解java的中文问题这是必须要了解的.但是了解这些仅仅只是一个开始,以下博客将侧重介绍java乱码是如何 ...

  3. 资料推荐--Google Java编码规范

    之前已经推荐过Google的Java编码规范英文版了: http://google-styleguide.googlecode.com/svn/trunk/javaguide.html 虽然这篇文章的 ...

  4. Java编码规范

    1. Java命名约定 除了以下几个特例之外,命名时应始终采用完整的英文描述符.此外,一般应采用小写字母,但类名.接口名以及任何非初始单词的第一个字母要大写.1.1 一般概念 n 尽量使用完整 ...

  5. 10个精妙的Java编码最佳实践

    这是一个比Josh Bloch的Effective Java规则更精妙的10条Java编码实践的列表.和Josh Bloch的列表容易学习并且关注日常情况相比,这个列表将包含涉及API/SPI设计中不 ...

  6. Eclipse formater(google Java 编码规范)

    1. 谷歌Java编码规范 http://google-styleguide.googlecode.com/svn/trunk/javaguide.html 2. 下载配置文件: https://co ...

  7. 【JAVA编码专题】总结

    第一部分:编码基础 为什么需要编码:用计算机看得懂的语言(二进制数)表示各种各样的字符. 一.基本概念 ASCII.Unicode.big5.GBK等为字符集,它们只定义了这个字符集内有哪些字符,以及 ...

  8. 【JAVA编码专题】深入分析 Java 中的中文编码问题

    http://www.ibm.com/developerworks/cn/java/j-lo-chinesecoding/ 几种常见的编码格式 为什么要编码 不知道大家有没有想过一个问题,那就是为什么 ...

  9. java中文乱码解决之道(四)—–java编码转换过程

    原文出处:http://cmsblogs.com/?p=1475 前面三篇博客侧重介绍字符.编码问题,通过这三篇博客各位博友对各种字符编码有了一个初步的了解,要了解java的中文问题这是必须要了解的. ...

  10. Java 编码 字符集

    Java 编码 字符集 @author ixenos 1.   字符集 a)    字符集建立了两字节Unicode码元序列与使用本地字符编码方式的字节序列之间的映射. b)    为了兼容其它命名, ...

随机推荐

  1. EMS修改邮箱容量限制的方法

    使用PowerShell命令完成邮箱数据库限制任务. 以Exchange管理员身份打开EMS控制台.在PowerShell命令提示符下,键入如下命令. Set-MailboxDatabase Test ...

  2. Input框搜索关键字高亮显示

    ruleTitle(text, val) { if (!val) return text; const result = text.replace( new RegExp(val, "g&q ...

  3. linux磁盘之分区类型id

    我们通过命令来查看一下linux系统定义的分区类型id及其意义(更改磁盘分区类型必须掌握)系统采样: [root@fp-web-130 ~]# cat /etc/redhat-release Cent ...

  4. 帝国cms插件 一键替换数据表中已发表文章的内容关键字

    你是不是也在优化网站,是不是网站发展了一段时间之后才来做优化的,这样当然就会导致已经发表文章里的内容关键字,不能得到替换了! 小编根据后台替换内容关键字的程序,重写了一段 通过运行单个页面就能直接替换 ...

  5. GIL全局解释器锁、协程运用、IO模型

    GIL全局解释器锁 一.什么是GIL 首先需要明确的一点是GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念.就好比C是一套语言(语法)标准,但是可以用不 ...

  6. 3道常见的vue面试题,你都会了吗?

    最近流传各大厂纷纷裁员,导致很多人"被迫"毕业,显然很多人还是想留级,无奈出现在名单中,只能感叹命运不公,不过拿了N+1,也算是很欣慰. 又得去面试了,接下来一起来巩固下vue的3 ...

  7. 日志、第三方模块(openpyxl模块)

    目录 1.日志模块 2.第三方模块 内容 日志模块 1.日志模块的主要组成部分 1.logger对象:产生日志 无包装的产品 import logging logger = logging.getLo ...

  8. 使用Visual Studio 2019开发Qt程序

    安装Qt 如标题,你首先需要到 http://download.qt.io/ 去下载并安装Qt,并在引导下安装MSVC组件(这里不做过多解释) Visual Studio 2019 配置 打开VS20 ...

  9. Python学习阵痛期

    Python和之前学习的Java语法上有较大的区别,例如Java中for循环常使用++自增符,在Python中是没有++的. 因为Python中整型.字符型等都是不可变的,一改变值就重新分配了新的内存 ...

  10. JavaScript学习总结4-规范

    昨天学习了JS的严格检查模式,今天做一点补充 1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 & ...