谁在偷看我代码?--- devtools-detector

3,596 阅读5分钟

前情提要:公司项目有需求,在打开该网页期间,不允许他人通过控制台以及保存页面的形式来查看代码,一旦有以上情况跳转至其他页面或者空白页(反正就是不给看)。

我分析了下需求,除了使用链接通过下载器下载页面控制不住,其余在该页面上通过按键来操作的都是可以实现的,但问题来了。保存和 F12 可以通过打开按键 code 去禁用,那么控制台呢?如何判断当前页面开启了控制台?然后开始了各种百度谷歌的流程,最终找到这个 devtools-detector 这个库来实现控制台打开与否的判断。

如何使用

使用很简单,通过 npmyarn 安装该库 npm install devtools-detector -S 引入:

import * as devtoolsDetector from 'devtools-detector';
const view = document.createElement('div');
document.body.appendChild(view);

// 1. add listener
devtoolsDetector.addListener((isOpen) => {
  view.innerHTML = isOpen ? '打开' : '关闭';
});
// 2. launch detect
devtoolsDetector.launch();

这样就可以在 addListener 中,监听到控制台的启动了。

noconsole.gif

文章到此为止就太简单了,以下会进行分析它的执行流程,以及是如何实现监听控制台的。

注:由于公司只使用谷歌浏览器,所以在进行实现判断检测的时候都采用的谷歌浏览器。但该库在使用时,几个浏览器支持度都比较好,较低版本的浏览器未测试。

源码目录

Snipaste_2021-07-20_15-31-38.png

源码目录从图片上可以看到还是算少的。其余的不看,重点分析 src 下辖的文件

├── src // 构建相关 
│   ├── checkers // 监测相关 
│   ├── classes // 主类 
│   ├── constants // 常数存放地 
│   ├── shared // 共享的方法和变量 
│   ├── types // TS的声明 
│   index.ts // 主入口文件

执行流程

index.ts 中可以看到,该库在主入口文件仅仅只做了一个简单的初始化,然后将监测函数变为 checkers 数组并传入。

// index.ts
import { DevtoolsDetector } from './classes/devtools-detector';
const defaultDetector = new DevtoolsDetector({
  // 会按照 checker 的顺序执行检查
  checkers: [
    checkers.elementIdChecker,
    checkers.regToStringChecker,
    checkers.functionToStringChecker,
    checkers.depRegToStringChecker,
    checkers.debuggerChecker,
  ],
});

./classes/devtools-detector 中可以看到该类的实现

// ./classes/devtools-detector.ts

export class DevtoolsDetector {
  private readonly _checkers: DevtoolsStatusChecker[];
  private _listeners: DevtoolsDetectorListener[] = [];
  private _isOpen = false;
  private _detectLoopStopped = true;
  private _detectLoopDelay = 500;
  private _timer!: number;

  constructor({ checkers }: DetectorOptions) {
    this._checkers = checkers.slice(); // 将传入的 checkers 赋值给 this._checkers
  }

	// 开启监听
  launch() {
    if (this._detectLoopDelay <= 0) {
      this.setDetectDelay(500);
    }
    if (this._detectLoopStopped) {
      this._detectLoopStopped = false;
      this._detectLoop();
    }
  }
	
	// 关闭监听
  stop() {
    if (!this._detectLoopStopped) {
      this._detectLoopStopped = true;
      clearTimeout(this._timer);
    }
  }
	
	// 判断当前是否开启监听
  isLaunch() {
    return !this._detectLoopStopped;
  }
	
	// 设置监听间隔
  setDetectDelay(value: number) {
    this._detectLoopDelay = value;
  }
	
	// 添加监听事件
  addListener(listener: DevtoolsDetectorListener) {
    this._listeners.push(listener);
  }
	
	// 移除监听事件
  removeListener(listener: DevtoolsDetectorListener) {
    this._listeners = this._listeners.filter((value) => value !== listener);
  }
	
	// 将添加的监听事件循环执行
  private _broadcast(value: DevtoolsDetail) {
    for (const listener of this._listeners) {
      try {
        listener(value.isOpen, value);
      } catch {
        /** nothing to do */
      }
    }
  }
	
	// 监听执行主体
  private async _detectLoop() {
    let isOpen = false;
    let checkerName = '';
    
    // 循环校验函数
    for (const checker of this._checkers) {
      // 先判断该校验函数在当前环境下能否使用
      const isEnable = await checker.isEnable();
      if (isEnable) {
        checkerName = checker.name;
        isOpen = await checker.isOpen();
      }

      // 任意一个 checker 返回 true 就视为 devtools 已打开
      if (isOpen) {
        break;
      }
    }
    
    // 更新类的 _isOpen 属性,并触发 _broadcast 函数
    if (isOpen != this._isOpen) {
      this._isOpen = isOpen;
      this._broadcast({
        isOpen,
        checkerName,
      });
    }

    // 判断间隔参数,是否继续执行 监听函数
    if (this._detectLoopDelay > 0) {
      this._timer = setTimeout(() => this._detectLoop(), this._detectLoopDelay);
    } else {
      this.stop();
    }
  }
}

从上述代码可以看出,流程很简洁,采用了 setTimeout 来进行间隔校验, 接下来,我们来查看下它实现的几种校验规则

校验规则

elementIdChecker

通过源码来看,似乎是对元素的 id 进行了劫持,来实现的方法。

const ele = createElement('div');
let isOpen = false;

Object.defineProperty(ele, 'id', {
  get() {
    isOpen = true;
    return elementIdChecker.name;
  },
  configurable: true,
});

export const elementIdChecker: DevtoolsStatusChecker = {
  name: 'element-id',
  async isOpen(): Promise<boolean> {
    isOpen = false;

    log(ele);
    clear();
    return isOpen;
  },
  async isEnable(): Promise<boolean> {
    return match({
      /** 匹配所有浏览器 */
      includes: [true],
      excludes: [isIE, isEdge, isFirefox], // 虽然
    });
  },
};

虽然它剔除了 ie,eage, firefox,但是我用了 chrome,opera 测试似乎也是无效 chrome

chrome.png Opera

opera.png 测试并未成功,不清楚这条校验规则实现的原理。难道是开启控制台后输出元素会获取 元素 的 id 属性?由此进行的判断

regToStringChecker

通过重写 reg 正则的 toString 的方法,来进行判断是否打开了控制台。

const reg = / /;
let isOpen = false;

reg.toString = () => {
  isOpen = true;
  return regToStringChecker.name;
};

export const regToStringChecker: DevtoolsStatusChecker = {
  name: 'reg-to-string',
  async isOpen(): Promise<boolean> {
    isOpen = false;

    log(reg);
    clear();

    return isOpen;
  },
  async isEnable(): Promise<boolean> {
    return match({
      /** 匹配所有浏览器 */
      includes: [true],
      /** 排除 webkit */
      excludes: [isWebkit],
    });
  },
};

该规则排除了 Webkit 内核的浏览器,firefox 输出时会直接调用其 reg.toString 方法, webkit 内核则不会 firxfox(支持有效)

regToString.gif chrome(直接了当不支持环境)

Snipaste_2021-07-20_17-47-46.png

functionToStringChecker

通过重写函数的 toString 方法,通过 console.log() 进行输出,webkit 内核浏览器则会调用函数的 toString 方法,firefox 则不会

function devtoolsTestFunction() {
  // nothing todo
}

let count = 0;

devtoolsTestFunction.toString = () => {
  count++;

  return '';
};

export const functionToStringChecker: DevtoolsStatusChecker = {
  name: 'function-to-string',
  async isOpen(): Promise<boolean> {
    count = 0;

    log(devtoolsTestFunction);
    clear();

    return count === 2;
  },
  async isEnable(): Promise<boolean> {
    return match({
      /** 匹配所有浏览器 */
      includes: [true],
      /** 排除 firefox 和  ipad 或 iphone 上的 chrome */
      excludes: [
        isFirefox,
        // ipad 或 iphone 上的 chrome
        (isIpad || isIphone) && isChrome,
      ],
    });
  },
};

chrome

functionToStringChecker.gif firefox(环境不支持)

Snipaste_2021-07-20_18-04-36.png

depRegToStringChecker

与上一条 regToStringChecker 方法一样通过重写 reg 的 toString 方法来实现校验。区别在于 之前是用 console.log 输出,这次是使用 console.table 进行输出。但经过我测试与第一条 elementIdChecker 一样都无效

const reg = / /;
let isOpen = false;

reg.toString = () => {
  isOpen = true;
  return depRegToStringChecker.name;
};

export const depRegToStringChecker: DevtoolsStatusChecker = {
  name: 'dep-reg-to-string',
  async isOpen(): Promise<boolean> {
    isOpen = false;

    table({ dep: reg });
    clear();

    return isOpen;
  },
  async isEnable(): Promise<boolean> {
    return match({
      /** 匹配所有浏览器 */
      includes: [true],
      /** 排除 firefox 和 ie */
      excludes: [isFirefox, isIE],
    });
  },
};

chrome(无效)

Snipaste_2021-07-20_18-12-11.png firefox(不支持环境)

Snipaste_2021-07-20_18-12-37.png

debuggerCheck

通过 debugger 特性(打开控制台一定进入 debugger,不打开则不进入)。来进行一个起止时间的相减。

// .checkers/debugger.checker.ts
import { DevtoolsStatusChecker } from '../types/devtools-status-checker.type';

function now() {
  if (performance) {
    return performance.now();
  } else {
    return Date.now();
  }
}

export const debuggerChecker: DevtoolsStatusChecker = {
  name: 'debugger-checker',
  async isOpen(): Promise<boolean> {
    const startTime = now(); // 开始时间
		
    // tslint:disable-next-line:no-empty only-arrow-functions
    // 主动进入 debugger
    (function () {}.constructor('debugger')());

    return now() - startTime > 100; // 结束 - 开始 > 100(0.1s)
  },
  async isEnable(): Promise<boolean> {
    return true;
  },
};

由以下动图可知,当未打开控制台时,由于不会出现 debugger 所以起止时间相减 < 100 ,但打开控制台,起止时间相减必定大于 100。当然由于 debugger 的原因,这条校验规则被排到了最后。

所有浏览器都支持

debuggerCheck.gif

结束

看完了这个库的源码,我的业务需求也成功了,但这个库只实现了控制台的问题,还有按键码需要去做处理。再在此添上完整功能

const view = document.createElement('div');
  document.body.appendChild(view);

  devtoolsDetector.addListener(function (isOpen) {
    if (isOpen) {
      window.location.href = 'about:blank';
    }
  });
  devtoolsDetector.launch();

  // 禁用 Crtl + S
  window.addEventListener(
    'keydown',
    function (e) {
      if (e.keyCode == 83 && (navigator.platform.match('Mac') ? e.metaKey : e.ctrlKey)) {
        e.preventDefault();
      }
    },
    false
  );

  // 禁用 f12
  document.onkeydown = function () {
    var e = window.event || arguments[0];
    if (e.keyCode == 123) {
      return false;
    }
  };

  // 禁用右键
  document.oncontextmenu = function () {
    return false;
  };

还有由于,这个库开启后是一直在运行的,所以通过别的页面打开控制台在进入该页面的话,一样会被监听到,总体就是该库监听控制台是否存在,不在乎何时打开。

小声的说,如果页面被当作 iframe 嵌入其他页面,也会受到影响 推荐一个录屏软件,LICEcap 免费好用