禅道Pms的前世今生
2024-09-13 09:14:17 # phpsec # cms

前言

禅道由禅道软件(青岛)有限公司开发,国产开源项目管理软件。它集项目集管理、产品管理、项目管理、质量管理、DevOps、知识库、BI效能、工作流、学堂、反馈管理、组织管理和事务管理于一体,是一款专业的研发项目管理软件,完整覆盖了研发项目管理的核心流程。禅道管理思想注重实效,功能完备丰富,操作简洁高效,界面美观大方,搜索功能强大,统计报表丰富多样,软件架构合理,扩展灵活,有完善的API可以调用。

禅道pms项目管理系统是基于zentaoPHP框架编写的,而该框架是mvc架构的框架,核心文件只有四个,要分析禅道pms历史漏洞就需要先学习一下系统的目录结构以及他怎么将请求分发到对应的控制器方法里面去的

ZentaoPHP

官方文档:https://www.zentao.net/book/zentaophphelp/about-1210.html

github:https://github.com/easysoft/zentaophp

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
config:           配置文件所在的目录。包含了config.php和my.php
db: demo应用所需要的blog.sql
framework: 包含了框架的核心文件。
js: 包含了js脚本文件。
lib: 包含了常用的类文件。
module: 模块目录,每个模块一个目录,存放在module目录下面。
extension: 扩展目录,二次开发的代码,扩展module的功能。
theme: 主题文件,包含了css文件和图片文件。
.htaccess: apache下面使用的url重写规则文件。
favicon.ico: 小图标文件。
index.php: 入口程序。

https://www.zentao.net/book/zentaophphelp/structure-1222.html

以下是pms的目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
config:           配置文件所在的目录。包含了config.php(默认配置)和my.php(用户配置:数据库连接密码)
db: 数据库sql文件,包含样例数据以及表结构
framework: 包含了ZentaoPHP框架的核心文件。
api: API目录
bin: 命令行工具启动
doc: 文档目录
extension: 扩展目录,二次开发的代码,扩展module的功能。
hook:
module: 模块目录(核心),每个模块一个目录,存放在module目录下面
lib: 包含了常用的类文件。
sdk: 客户端接口,可以通过调用sdk/php下的文件调用一些功能,需要指定pms的地址
tmp: 临时目录,包含session,log,cache,backup等等
www: 网站目录
theme: 主题文件,包含了css文件和图片文件。
.htaccess: apache下面使用的url重写规则文件。
favicon.ico: 小图标文件。
index.php: 入口程序。

模块目录

禅道的功能都是由一个个的模块组成的,每个模块会对应到module下面的一个目录,比如project、user等模块。每个模块下按照MVC进行组织划分,有自己的control(控制层)、 model(模型层)和view/ui(视图层)。同时我们还补充了其他几个辅助的概念:config(配置)、lang(语言)、 css(样式)、js(js脚本)、zen(控制层子层)、tao(模型层子层)。

1
2
3
4
5
6
7
8
9
10
control.php 控制层,页面访问的入口代码和逻辑
zen.php 控制子层,control层的内部代码会放在zen层
model.php 模型层,主要是对数据库的操作代码
tao.php 模型子层,model层的一些公用或基础数据库操作放在这里
view目录 旧的视图层,18版本及之前的视图文件存放在这里
ui目录 新的视图层,20版本之后新的视图文件
config目录 模块本身的配置项
lang目录 模块的语言文件
css目录 前端样式文件
js目录 前端JavaScript代码

其中重点关注control.phpmodel.php就行,另外的zen.phptao.php都是他们各自的子类,起一个拓展的作用,然后config.php相当于只作用于该模块下的配置

zen层是禅道20版本之后增加的新的逻辑分层,主要解决的是control层代码臃肿,将control层的子逻辑放在zen层。

control层可以通过诸如 $this->oaZen->getList(); 的方式调用 zen层的方法。

tao层同理,都是20.x版本的特性包括后门的view ui

禅道的视图层在ui目录里编写,18系列版本的视图文件在view目录里,20版本的代码都是在ui目录进行编写。

之前禅道的视图文件直接使用PHP+原始HTML拼接进行渲染的,代码封装度不够、很多组件无法复用,导致开发效率低、页面风格不一致。为了解决这些问题,禅道视图层采用了新的组件式开发方式zin进行编写。

框架架构

https://www.zentao.net/book/zentaophphelp/basic-1223.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<?php
/**
* The router file of ZenTaoPMS.
*
* All request should be routed by this router.
*
* @copyright Copyright 2009-2015 禅道软件(青岛)有限公司(ZenTao Software (Qingdao) Co., Ltd. www.cnezsoft.com)
* @license ZPL(http://zpl.pub/page/zplv12.html) or AGPL(https://www.gnu.org/licenses/agpl-3.0.en.html)
* @author Chunsheng Wang <chunsheng@cnezsoft.com>
* @package ZenTaoPMS
* @version $Id: index.php 5036 2013-07-06 05:26:44Z wyd621@gmail.com $
* @link http://www.zentao.net
*/
/* Set the error reporting. */
error_reporting(E_ALL);

/* Start output buffer. */
ob_start();

/* Set cookie_httponly. */
ini_set("session.cookie_httponly", 1);

/* Load the framework. */
include '../framework/router.class.php';
include '../framework/control.class.php';
include '../framework/model.class.php';
include '../framework/helper.class.php';

/* Log the time and define the run mode. */
$startTime = getTime();

/* Instance the app. */
$app = router::createApp('pms', dirname(dirname(__FILE__)), 'router');

/* installed or not. */
if(!$app->checkInstalled()) die(header('location: install.php'));

/* Check for need upgrade. */
$config->installedVersion = $app->getInstalledVersion();
if($config->version != $config->installedVersion) die(header('location: upgrade.php'));

/* Run the app. */
$app->setStartTime($startTime);
$common = $app->loadCommon();

/* Check the request is getconfig or not. */
if(isset($_GET['mode']) and $_GET['mode'] == 'getconfig') die(helper::removeUTF8Bom($app->exportConfig()));

/* Remove install.php and upgrade.php. */
if(file_exists('install.php') or file_exists('upgrade.php'))
{
if(!empty($config->inContainer) && !isset($_SESSION['installing']))
{
if(file_exists('install.php')) unlink('install.php');
if(file_exists('upgrade.php')) unlink('upgrade.php');
}
else
{
$undeletedFiles = array();
if(file_exists('install.php')) $undeletedFiles[] = '<strong style="color:#ed980f">install.php</strong>';
if(file_exists('upgrade.php')) $undeletedFiles[] = '<strong style="color:#ed980f">upgrade.php</strong>';
$wwwDir = dirname(__FILE__);
if($undeletedFiles)
{
echo "<html><head><meta charset='utf-8'></head>
<body><table align='center' style='width:700px; margin-top:100px; border:1px solid gray; font-size:14px;'><tr><td style='padding:8px'>";
echo "<div style='margin-bottom:8px;'>安全起见,请删除 <strong style='color:#ed980f'>{$wwwDir}</strong> 目录下的 " . join(' 和 ', $undeletedFiles) . " 文件。</div>";
echo "<div>Please remove " . join(' and ', $undeletedFiles) . " under <strong style='color:#ed980f'>$wwwDir</strong> dir for security reason.</div>";
die("</td></tr></table></body></html>");
}
}
}

/* If client device is mobile and version is pro, set the default view as mthml. */
if($app->clientDevice == 'mobile' and (strpos($config->version, 'pro') === 0 or strpos($config->version, 'biz') === 0 or strpos($config->version, 'max') === 0) and $config->default->view == 'html') $config->default->view = 'mhtml';
if(!empty($_GET['display']) && $_GET['display'] == 'card') $config->default->view = 'xhtml';

try
{
$app->parseRequest();
if(!$app->setParams()) helper::end();
$common->checkMaintenance();
$common->checkPriv();
if(!$common->checkIframe()) helper::end();

if(session_id() != $app->sessionID && strpos($_SERVER['HTTP_USER_AGENT'], 'xuanxuan') === false) helper::restartSession($app->sessionID);

$app->loadModule();
}
catch (EndResponseException $endResponseException)
{
echo $endResponseException->getContent();
}

/* Flush the buffer. */
echo helper::removeUTF8Bom(ob_get_clean());

通过官方流程图,我们可以知道,程序通过入口文件处理请求,总共有两种请求方式,GET传参和pathinfo

  1. 导入ZentaoPHP的核心依赖文件router.class.phpcontrol.class.phpmodel.class.phphelper.class.php
  2. 调用router::createApp创建app实例
  3. 加载common模块(加载语言)
  4. 调用parseRequest处理请求参数
  5. 调用loadModule执行对应的方法

当然实际的入口文件还存在一些其他操作如检查是否安装,检查是否需要更新,检查客户端版本,记录请求时间等等

路由分发原理分析

创建App实例

  1. 调用基类的createApp创建app,这里默认的className是router类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 创建一个应用。
* Create an application.
*
* @param string $appName 应用名称。 The name of the app.
* @param string $appRoot 应用根路径。The root path of the app.
* @param string $className 应用类名,如果对router类做了扩展,需要指定类名。When extends router class, you should pass in the child router class name.
* @param string $mode 应用模式。 The mode of the app. running|installing|upgrading
* @static
* @access public
* @return static the app object
*/
public static function createApp($appName = 'demo', $appRoot = '', $className = '', $mode = 'running')
{
if(empty($className)) $className = self::class;
return new $className($appName, $appRoot, $mode);
}
  1. 初始化app对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* 构造方法, 设置路径,类,超级变量等。注意:
* 1.应该使用createApp()方法实例化router类;
* 2.如果$appRoot为空,框架会根据$appName计算应用路径。
*
* The construct function.
* Prepare all the paths, classes, super objects and so on.
* Notice:
* 1. You should use the createApp() method to get an instance of the router.
* 2. If the $appRoot is empty, the framework will compute the appRoot according the $appName
*
* @param string $appName the name of the app
* @param string $appRoot the root path of the app
* @param string $mode the mode of the app running|installing|upgrading
* @access public
* @return void
*/
public function __construct(string $appName = 'demo', string $appRoot = '', string $mode = 'running')
{
if($mode != 'running') $this->{$mode} = true;

$this->setPathFix();
$this->setBasePath();
$this->setFrameRoot();
$this->setCoreLibRoot();
$this->setAppRoot($appName, $appRoot);
$this->setTmpRoot();
$this->setCacheRoot();
$this->setLogRoot();
$this->setConfigRoot();
$this->setModuleRoot();
$this->setWwwRoot();
$this->setThemeRoot();
$this->setDataRoot();
$this->loadMainConfig();

$this->loadClass('front', $static = true);
$this->loadClass('filter', $static = true);
$this->loadClass('form', $static = true);
$this->loadClass('dbh', $static = true);
$this->loadClass('sqlite', $static = true);
$this->loadClass('dao', $static = true);
$this->loadClass('mobile', $static = true);

$this->setCookieSecure();
$this->setDebug();
$this->setErrorHandler();
$this->setTimezone();

if($this->config->framework->autoConnectDB) $this->connectDB();

$this->setupProfiling();
$this->setupXhprof();

$this->setEdition();

$this->setClient();

$this->loadCacheConfig();
}

该对象有很多属性,每个属性在代码里面都有注释,表明其含义

  1. 加载默认配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 加载整个应用公共的配置文件。
* Load the common config files for the app.
*
* @access public
* @return void
*/
public function loadMainConfig()
{
/* 初始化$config对象。Init the $config object. */
global $config, $filter;
if(!is_object($config)) $config = new config();
$this->config = $config;

/* 加载主配置文件。 Load the main config file. */
$mainConfigFile = $this->configRoot . 'config.php';
if(!file_exists($mainConfigFile)) $this->triggerError("The main config file $mainConfigFile not found", __FILE__, __LINE__, true);
include $mainConfigFile;
}

这部分代码就比较简单了,常见了一个全局变量config,并且赋值了一份在app实例里面,这里的app对象其实就是router对象,然后再配置路径下寻找config.php并包含

  1. 加载库文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 从类库中加载一个类文件。
*
* Load a class file.
*
* @param string $className the class name
* @param bool $static statis class or not
* @access public
* @return object|bool the instance of the class or just true.
*/
public function loadClass($className, $static = false)
{
$className = strtolower($className);

/* 搜索$coreLibRoot(Search in $coreLibRoot) */
$classFile = $this->coreLibRoot . $className . DS . $className . '.class.php';
if(!helper::import($classFile)) $this->triggerError("class file $classFile not found", __FILE__, __LINE__, true);

/* 如果是静态调用,则返回(If static, return) */
if($static) return true;

/* 实例化该类(Instance it) */
global ${$className};
if(!class_exists($className)) $this->triggerError("the class $className not found in $classFile", __FILE__, __LINE__, true);
if(!is_object(${$className})) ${$className} = new $className();
return ${$className};
}

首先这里会根据static参数判断是否需要实例化对象,如果开启了static则直接包含就行,不会进行实例化

这里包含文件使用的是import方法,我们跟进一下这个通用的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 使用helper::import()来引入文件,不要直接使用include或者require.
* Using helper::import() to import a file, instead of include or require.
*
* @param string $file the file to be imported.
* @static
* @access public
* @return bool
*/
static public function import($file)
{
$file = realpath($file);
if($file === false) return false;

if(isset(self::$includedFiles[$file])) return true;
if(!is_file($file)) return false;

include $file;
self::$includedFiles[$file] = true;
return true;
}

实际上就是自己实现了一个include_once

  1. 根据已有配置初始化一些app对象的信息

比如说设置debug,设置时区,加载缓存配置等等,这里需要关注一下loadCacheConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 从数据库加载缓存配置。
* Load the cache config from the database.
*
* @access public
* @return void
*/
public function loadCacheConfig()
{
if(!$this->checkInstalled()) return false;

$globalCache = $this->dbQuery("SELECT value FROM " . TABLE_CONFIG . " WHERE `module` = 'common' AND `section` = 'global' AND `key` = 'cache' LIMIT 1")->fetch();
if(!$globalCache) return false;

$caches = json_decode($globalCache->value);
foreach($caches as $cacheKey => $cache)
{
if(!isset($this->config->cache->$cacheKey)) $this->config->cache->$cacheKey = new stdClass();

foreach($cache as $key => $value) $this->config->cache->$cacheKey->$key = $value;
}
}

WHEREmodule= 'common' ANDsection= 'global' ANDkey = 'cache' LIMIT 1,这里获取所有common模块,global范围的cache配置的值,该值为一个json字符串

这里实际上就是加载数据库中的缓存模块的配置,而不是缓存到数据库中的配置

检查安装更新

1
2
3
4
5
6
/* installed or not. */
if(!$app->checkInstalled()) die(header('location: install.php'));

/* Check for need upgrade. */
$config->installedVersion = $app->getInstalledVersion();
if($config->version != $config->installedVersion) die(header('location: upgrade.php'));

启动app对象

1
2
3
/* Run the app. */
$app->setStartTime($startTime);
$common = $app->loadCommon();

设置启动时间并调用loadCommon加载common模块,这里我们跟进一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 加载common模块。
*
* common模块比较特别,它会执行几乎每次请求都需要执行的操作,例如:
* 打开session,检查权限等等。
* 加载完$lang, $config, $dbh后,需要在入口文件(www/index.php)中手动调用该方法。
*
* Load the common module
*
* The common module is a special module, which can be used to do some common things. For example:
* start session, check privilege and so on.
* This method should called manually in the router file(www/index.php) after the $lang, $config, $dbh loaded.
*
* @access public
* @return object|bool the common model object or false if not exits.
*/
public function loadCommon()
{
$this->setModuleName('common');
$commonModelFile = $this->setModelFile('common');
if(!file_exists($commonModelFile)) return false;

helper::import($commonModelFile);

if($this->config->framework->extensionLevel == 0 and class_exists('commonModel'))
{
$common = new commonModel();
}
elseif($this->config->framework->extensionLevel > 0 and class_exists('extCommonModel'))
{
$common = new extCommonModel();
}
elseif(class_exists('commonModel'))
{
$common = new commonModel();
}
else
{
return false;
}

$this->loadLang('company');
$common->setUserConfig();

$this->setDebug();

return $common;
}

这里会根据不同的版本创建不同的model对象,然后model

然后再commonmodel对象里面加载了语言

接着在后门加载tao类,这个属于拓展类

接着再往后会设置用户配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 设置用户配置信息。
* Set config of user.
*
* @access public
* @return void
*/
public function setUserConfig()
{
$this->sendHeader();
$this->setCompany();
$this->setUser();
$this->setApproval();
$this->loadConfigFromDB();
$this->loadCustomFromDB();
$this->initAuthorize();

if(!$this->checkIP()) return print($this->lang->ipLimited);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public function loadConfigFromDB()
{
if($this->app->isServing() || (defined('RUN_MODE') && RUN_MODE == 'api'))
{
$this->loadModel('setting');
$xxItems = $this->setting->getItems('owner=system&module=common&section=xuanxuan');
$xxConfig = array();
foreach($xxItems as $xxItem) $xxConfig[$xxItem->key] = $xxItem->value;
if(empty($xxConfig['key']))
{
$this->setting->setItem('system.common.xuanxuan.turnon', '0');
$this->setting->setItem('system.common.xuanxuan.key', $this->setting->computeSN());
}
if(!isset($xxConfig['chatPort'])) $this->setting->setItem('system.common.xuanxuan.chatPort', '11444');
if(!isset($xxConfig['commonPort'])) $this->setting->setItem('system.common.xuanxuan.commonPort', '11443');
if(!isset($xxConfig['ip'])) $this->setting->setItem('system.common.xuanxuan.ip', '0.0.0.0');
if(!isset($xxConfig['uploadFileSize'])) $this->setting->setItem('system.common.xuanxuan.uploadFileSize', '20');
if(!isset($xxConfig['https']) and !isset($xxConfig['isHttps'])) $this->setting->setItem('system.common.xuanxuan.https', 'off');
}
/* Get configs of system and current user. */
$account = isset($this->app->user->account) ? $this->app->user->account : '';
if($this->config->db->name) $config = $this->loadModel('setting')->getSysAndPersonalConfig($account);
$this->config->system = isset($config['system']) ? $config['system'] : array();
$this->config->personal = isset($config[$account]) ? $config[$account] : array();

$this->commonTao->updateDBWebRoot($this->config->system);

/* Override the items defined in config/config.php and config/my.php. */
if(isset($this->config->system->common)) $this->app->mergeConfig($this->config->system->common, 'common');
if(isset($this->config->personal->common)) $this->app->mergeConfig($this->config->personal->common, 'common');

$this->config->disabledFeatures = $this->config->disabledFeatures . ',' . $this->config->closedFeatures;
}

这里会加载用户和custom的信息从setting模块里面

处理配置模式&删除安装或更新脚本&兼容手机端

这部分简单看一下就行

处理请求

这部分为控制器分发的核心,通过入口文件分发到指定的控制器方法上去

1
2
3
4
5
6
7
8
9
10
11
12
13
try {
$app->parseRequest();
if (!$app->setParams()) helper::end();
$common->checkMaintenance();
$common->checkPriv();
if (!$common->checkIframe()) helper::end();

if (session_id() != $app->sessionID && strpos($_SERVER['HTTP_USER_AGENT'], 'xuanxuan') === false) helper::restartSession($app->sessionID);

$app->loadModule();
} catch (EndResponseException $endResponseException) {
echo $endResponseException->getContent();
}
  1. parseRequest解析请求方法

总共有两种请求方法,GET模式和PATH_INFO模式,相关的规则可以参考官方文档

https://www.zentao.net/book/zentaophphelp/request-1229.html

这里我就使用GET模式进行演示

  1. 解析参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* GET请求方式解析,获取$uri和$viewType。
* Parse GET, get $uri and $viewType.
*
* @access public
* @return void
*/
public function parseGET()
{
if(isset($_GET[$this->config->viewVar]))
{
$this->viewType = $_GET[$this->config->viewVar];
if(strpos((string) $this->config->views, ',' . $this->viewType . ',') === false) $this->viewType = $this->config->default->view;
}
else
{
$this->viewType = $this->config->default->view;
}
$this->uri = $_SERVER['REQUEST_URI'];
}

这里处理传入的t参数用来指定展示的类型type,默认是html,当然也可以使用json,接着把REQUEST_URI记录到uri属性中

  1. 设置路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 设置路由(GET 方式):
* 1.设置模块名;
* 2.设置方法名;
* 3.设置控制器文件。
*
* Set the route according to GET.
* 1. set the module name.
* 2. set the method name.
* 3. set the control file.
*
* @access public
* @return void
*/
public function setRouteByGET()
{
$moduleName = isset($_GET[$this->config->moduleVar]) ? strtolower((string) $_GET[$this->config->moduleVar]) : $this->config->default->module;
$methodName = isset($_GET[$this->config->methodVar]) ? strtolower((string) $_GET[$this->config->methodVar]) : $this->config->default->method;
$this->setModuleName($moduleName);
$this->setMethodName($methodName);
$this->setControlFile();
}

总共三个步骤,设置模块名以及设置方法名,后面会根据这里设置的模块方法进行调用,最后一步是设置控制器文件,因为要调用控制器的任意方法就需要先包含对应的control文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
* 检查请求的模块和方法是否应该调用工作流引擎进行处理。
* Check if the requested module and method should call the workflow engine for processing.
*
* 处理逻辑:
* Processing logic:
* 1、如果当前版本不是企业版,或者当前请求处于安装模式或升级模式,调用父类方法并返回。
* 1. If the current version is not the enterprise version, or if the current request is in install mode or upgrade mode, call the parent class method and return.
*
* 2、如果当前请求的模块在TABLE_WORKFLOW表中不存在,调用父类方法并返回。
* 2. If the currently requested module does not exist in the TABLE_WORKFLOW table, call the parent class method and return.
*
* 3、如果当前请求的模块在TABLE_WORKFLOW表中存在并且是内置模块,并且请求的方法名是browselabel,则修改请求的模块名为flow,修改请求的方法名为browse,重新设置URI参数,调用父类方法并返回。
* 3. If the currently requested module exists in the TABLE_WORKFLOW table and is a built-in module, and the requested method name is
* browselabel, rename the module of the request to flow and the method of the request to browse, and reset the URI, call the parent class method and return.
*
* 4、如果不满足3中的条件但当前请求的方法在TABLE_WORKFLOWACTION表中存在,且方法扩展类型为重写,则修改请求的模块名为flow,方法名根据5中的规则修改,重新设置URI参数,调用父类方法并返回。
* 4. If the condition of 3 is not satisfied but the currently requested method exists in the TABLE_WORKFLOWACTION table, and the method
* extension type is overwrite, rename the module of the request to flow, and rename the method of the request according to the rule in 5.
* Then reset the URI, call the parent class method and return.
*
* 5、如果当前请求的方法名为browse、create、edit、view、delete、export中任意一个,则方法名不变,否则方法名改为operate。
* 5. If the currently requested method is named any one of browse, create, edit, view, delete, or export, the method name is unchanged, otherwise the method name is changed to operate.
*
* @param bool $exitIfNone 没有找到该控制器文件的情况:如果该参数为true,则终止程序;如果为false,则打印错误日志
* The controller file was not found: if the parameter is true, the program is terminated;
* if false, the error log is printed.
* @access public
* @return bool
*/
public function setControlFile($exitIfNone = true)
{
/* Set raw module and method name for fetch control. */
if(empty($this->rawModule)) $this->rawModule = $this->moduleName;
if(empty($this->rawMethod)) $this->rawMethod = $this->methodName;

/* If is not a biz version or is in install mode or in in upgrade mode, call parent method. */
if($this->config->edition == 'open' or $this->installing or $this->upgrading) return parent::setControlFile($exitIfNone);

/* Check if the requested module is defined in workflow. */
$flow = $this->dbQuery("SELECT * FROM " . TABLE_WORKFLOW . " WHERE `module` = '$this->moduleName'")->fetch();
if(!$flow) return parent::setControlFile($exitIfNone);
if($flow->status != 'normal') helper::end("<html><head><meta charset='utf-8'></head><body>{$this->lang->flowNotRelease}</body></html>");

/**
* 工作流中配置的标签应该请求browse方法,而某些内置流程本身包含browse方法。在这里处理请求的时候会无法区分是内置的browse方法还是工作
* 流标签的browse方法,为了避免此类冲突,在工作流中配置出的标签请求的方法改为browseLabel,在设置控制器文件时需要将其重设为browse。
* Tags configured in the workflow should request the browse method, and some built-in processes themselves contain the browse
* method. When processing a request here, it is impossible to distinguish between the built-in browse method and the browse
* method of the workflow tag. In order to avoid such conflicts, the method of configuring the label request in the workflow
* is changed to browseLabel, which needs to be reset to browse when setting the controller file.
*/
if($flow->buildin && $this->methodName == 'browselabel')
{
$this->rawModule = $this->moduleName;
$this->rawMethod = 'browse';
$this->isFlow = true;

$moduleName = 'flow';
$methodName = 'browse';

$this->setFlowURI($moduleName, $methodName);
}
else
{
$action = $this->dbQuery("SELECT * FROM " . TABLE_WORKFLOWACTION . " WHERE `module` = '$this->moduleName' AND `action` = '$this->methodName' AND `vision` = '{$this->config->vision}'")->fetch();
if(zget($action, 'extensionType') == 'override')
{
$this->rawModule = $this->moduleName;
$this->rawMethod = $this->methodName;
$this->isFlow = true;

$this->loadModuleConfig('workflowaction');

$moduleName = 'flow';
$methodName = $this->methodName;
/*
* 工作流中除了内置方法外的方法,如果是批量操作调用batchOperate方法,其它操作调用operate方法来执行。
* In addition to the built-in methods in the workflow, if the batch operation calls the batchOperate method, other operations call the operate method to execute.
*/
if(!in_array($this->methodName, $this->config->workflowaction->default->actions))
{
if($action->type == 'single') $methodName = 'operate';
if($action->type == 'batch') $methodName = 'batchOperate';
}

$this->setFlowURI($moduleName, $methodName);
}
}

/* Call method of parent. */
return parent::setControlFile($exitIfNone);
}

正常版本调用到if($this->config->edition == 'open' or $this->installing or $this->upgrading) return parent::setControlFile($exitIfNone);就结束了,也就是开源版,只有在非开源版本如企业版就会有工作流workflow相关代码逻辑

回到开源版,看一下他是怎么通过模块名找到对应的control文件并进行包含的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 获取一个模块的路径。
* Get the path of one module.
*
* @param string $appName the app name
* @param string $moduleName the module name
* @access public
* @return string the module path
*/
public function getModulePath($appName = '', $moduleName = '')
{
if($moduleName == '') $moduleName = $this->moduleName;
$moduleName = strtolower($moduleName);

if($this->checkModuleName($moduleName))
{
$modulePath = $this->getExtensionRoot() . 'saas' . DS . $moduleName . DS;
if(is_dir($modulePath) and (file_exists($modulePath . 'control.php') or file_exists($modulePath . 'model.php'))) return $modulePath;

/* 1. 尝试在定制开发中寻找。 Finally, try to find the module in the custom dir. */
$modulePath = $this->getExtensionRoot() . 'custom' . DS . $moduleName . DS;
if(is_dir($modulePath) and (file_exists($modulePath . 'control.php') or file_exists($modulePath . 'model.php'))) return $modulePath;

/* 2. 如果设置过vision,尝试在vision中查找。 If vision is set, try to find the module in the vision. */
if($this->config->vision != 'rnd')
{
$modulePath = $this->getExtensionRoot() . $this->config->vision . DS . $moduleName . DS;
if(is_dir($modulePath) and (file_exists($modulePath . 'control.php') or file_exists($modulePath . 'model.php'))) return $modulePath;
}

/* 3. 尝试查找商业版本是否有此模块。 Try to find the module in other editon. */
if($this->config->edition != 'open')
{
$modulePath = $this->getExtensionRoot() . $this->config->edition . DS . $moduleName . DS;
if(is_dir($modulePath) and (file_exists($modulePath . 'control.php') or file_exists($modulePath . 'model.php'))) return $modulePath;
}

/* 4. 尝试查找喧喧是否有此模块。 Try to find the module in xuan. */
$modulePath = $this->getExtensionRoot() . 'xuan' . DS . $moduleName . DS;
if(is_dir($modulePath) and (file_exists($modulePath . 'control.php') or file_exists($modulePath . 'model.php'))) return $modulePath;

/* 5. 使用通用版本里的模块。 If module is in the open edition, use it. */
return $this->getModuleRoot($appName) . $moduleName . DS;
}
}

可以看到这里有一个查找顺序,通用版本的逻辑优先级最低

  1. 设置参数setParams

这一部分用来设置其他的参数,实际上就是删掉之前的m f等参数留下干净的参数

接着会过滤参数

  1. 检查系统状态

  1. 鉴权checkPriv

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* 禅道鉴权核心方法,如果用户没有当前模块、方法的权限,则跳转到登录页面或者拒绝页面。
* Check the user has permission to access this method, if not, locate to the login page or deny page.
*
* @access public
* @return void
*/
public function checkPriv()
{
try
{
$module = $this->app->getModuleName();
$method = $this->app->getMethodName();
if($this->app->isFlow)
{
$module = $this->app->rawModule;
$method = $this->app->rawMethod;
}

$openMethods = array(
'user' => array('deny', 'logout'),
'my' => array('changepassword'),
'message' => array('ajaxgetmessage'),
);

if(!empty($this->app->user->modifyPassword) and (!isset($openMethods[$module]) or !in_array($method, $openMethods[$module]))) return helper::header('location', helper::createLink('my', 'changePassword'));
if(!$this->loadModel('user')->isLogon() and $this->server->php_auth_user) $this->user->identifyByPhpAuth();
if(!$this->loadModel('user')->isLogon() and $this->cookie->za) $this->user->identifyByCookie();
if($this->isOpenMethod($module, $method)) return true;

if(isset($this->app->user))
{
if($this->app->tab == 'project')
{
$this->resetProjectPriv(); // 项目有继承和重新定义两种权限,在此处需要重置权限。
if(commonModel::hasPriv($module, $method)) return true;
}

$this->app->user = $this->session->user;
if(!commonModel::hasPriv($module, $method))
{
if($module === 'feedback' && stripos(',view,adminview,', ",$method,") !== false && ($method === 'view' && commonModel::hasPriv('feedback', 'adminview') || $method === 'adminview' && commonModel::hasPriv('feedback', 'view'))) return true; // Make both feedback view and adminview privs interchangeable.

if($module == 'story' and !empty($this->app->params['storyType']) and strpos(",story,requirement,", ",{$this->app->params['storyType']},") !== false) $module = $this->app->params['storyType'];
$this->deny($module, $method);
}
}
else
{
$uri = $this->app->getURI(true);
if($module == 'message' and $method == 'ajaxgetmessage')
{
$uri = helper::createLink('my');
}
elseif(helper::isAjaxRequest())
{
helper::end(json_encode(array('result' => false, 'message' => $this->lang->error->loginTimeout))); // Fix bug #14478.
}

$referer = helper::safe64Encode($uri);
helper::end(js::locate(helper::createLink('user', 'login', "referer=$referer")));
}
}
catch(EndResponseException $endResponseException)
{
echo $endResponseException->getContent();
helper::end();
}
}

核心鉴权方法,之前有些历史漏洞权限绕过就是针对于该方法进行的绕过

加载模块执行方法

真正执行控制器方法就在这一步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 加载一个模块:
* 1. 引入控制器文件或扩展的方法文件;
* 2. 创建control对象;
* 3. 解析url,得到请求的参数;
* 4. 使用call_user_function_array调用相应的方法。
*
* Load a module.
* 1. include the control file or the extension action file.
* 2. create the control object.
* 3. set the params passed in through url.
* 4. call the method by call_user_function_array
*
* @access public
* @return bool|object if the module object of die.
*/
public function loadModule()
{
if(is_null($this->params) and !$this->setParams())
{
$this->outputXhprof();
return false;
}

/* 调用该方法 Call the method. */
$module = $this->control;
$method = $this->methodName ? $this->methodName : $this->config->default->method;

call_user_func_array(array($module, $method), $this->params);
$this->checkAPIFile();
$this->outputXhprof();

return $module;
}

可以看到核心其实就是调用的call_user_func_array(array($module, $method), $this->params);去对应的方法,最后控制器将浏览器返回的结果输出到输出缓冲区,最后调用echo helper::removeUTF8Bom(ob_get_clean());返回内容

数据库访问类dao

https://www.zentao.net/book/extension-dev/dao-1332.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
fetch():         获得满足条件的第一次记录,返回的是对象格式。
fetch($filed): 获得满足条件的第一个记录的字段$field对应的值。
fetchAll(): 获得满足条件的所有记录,以数组格式返回,索引为0-n
fetchAll($key): 获得满足条件的所有记录,并使用字段$key作为索引值。
fetchPairs($key, $value): 返回键值对的列表。如果不指定参数,则取返回记录中的第一个字段作为key,第二个字段作为value。
fetchGroup($group, $key): 把满足条件的记录按照$group字段进行分组。比如把所有$status=active的放在一起。

普通的查询:查询account=wwccss的记录。
$this->dao->select('*')
->from('user')
->where('account')
->eq('wwccss')
->fetch();

再复杂一点,加入andWhere (或者orWhere)
$this->dao->select('*')->from('user')
->where('id')->gt(10)
->andWhere('age')->lt(20)
->orderBy('id desc')
->limit('1,10')
->fetchAll()

左连接查询
$this->dao->select('t1.*, t2.*')->from('user')->alias('t1')->leftJoin('userGroup')->alias('t2')->on('t1.account = t2.account')->fetchAll();

其他便利的方法:
$this->dao->findByAccount($account)->from('user')->fetch(); // 魔术方法,按照account进行查询。
$this->dao->select('*')->from('user')->fetchAll('account'); // 返回的结果中,以account为key。
$this->dao->select('account, realname')->from('user')->fetchPairs(); // 返回account=>realname的键值对。
$this->dao->select('class, account, realname')->from('user')->fetchGroup('class'); // 按照所属的class进行分组

根据条件拼装SQL:beginIF, FI()
$this->dao->select('*')->from('user')->where('id')->gt(10)->beginIF($class == 'online')->andWhere('status')->eq('online')->fi()->fetchAll();

插入语句:
使用一个data对象来更新。data对象的key对应到数据表中字段名。

$user->account = 'wwccss';
$user->password = '123456';
$this->dao->insert('user')->data($user)->exec();
或者一个字段一个字段更新:

$this->dao->insert('user')
->set('account')->eq($account)
->set('password')->eq($password)
->exec();

获得后插入的记录id
echo $this->dao->lastInsertID();

更新语句:
$user->name = 'wwccss';
$user->age = 10;
$this->dao->update('user')->data($user)->where('id')->eq($userid)->limit(1)->exec();

$this->dao->update('user')
->set('account')->eq($account)
->set('password')->eq($password)
->exec()

$this->dao->replace('user')->data($user)->exec();

历史漏洞

参考文章

1
2
3
4
https://www.zentao.net/book/extension-dev/custom-dev-1319.html
https://www.zentao.net/book/zentaopms/38.html
https://xz.aliyun.com/t/8692
https://xz.aliyun.com/t/13143