* @author Bjoern Schiessle * @author Christoph Schaefer "christophł@wolkesicher.de" * @author Christoph Wurst * @author Daniel Kesselberg * @author Daniel Rudolf * @author Greta Doci * @author Joas Schilling * @author Julius Haertl * @author Julius Härtl * @author Lukas Reschke * @author Maxence Lange * @author Morris Jobke * @author Robin Appelman * @author Roeland Jago Douma * @author Thomas Müller * @author Tobia De Koninck * @author Vincent Petry * * @license AGPL-3.0 * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License, version 3, * as published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License, version 3, * along with this program. If not, see * */ namespace OC\App; use OC\AppConfig; use OCP\App\AppPathNotFoundException; use OCP\App\IAppManager; use OCP\App\ManagerEvent; use OCP\ICacheFactory; use OCP\IConfig; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; use OCP\IUserSession; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; class AppManager implements IAppManager { /** * Apps with these types can not be enabled for certain groups only * @var string[] */ protected $protectedAppTypes = [ 'filesystem', 'prelogin', 'authentication', 'logging', 'prevent_group_restriction', ]; /** @var IUserSession */ private $userSession; /** @var IConfig */ private $config; /** @var AppConfig */ private $appConfig; /** @var IGroupManager */ private $groupManager; /** @var ICacheFactory */ private $memCacheFactory; /** @var EventDispatcherInterface */ private $dispatcher; /** @var LoggerInterface */ private $logger; /** @var string[] $appId => $enabled */ private $installedAppsCache; /** @var string[] */ private $shippedApps; private array $alwaysEnabled = []; private array $defaultEnabled = []; /** @var array */ private $appInfos = []; /** @var array */ private $appVersions = []; /** @var array */ private $autoDisabledApps = []; public function __construct(IUserSession $userSession, IConfig $config, AppConfig $appConfig, IGroupManager $groupManager, ICacheFactory $memCacheFactory, EventDispatcherInterface $dispatcher, LoggerInterface $logger) { $this->userSession = $userSession; $this->config = $config; $this->appConfig = $appConfig; $this->groupManager = $groupManager; $this->memCacheFactory = $memCacheFactory; $this->dispatcher = $dispatcher; $this->logger = $logger; } /** * @return string[] $appId => $enabled */ private function getInstalledAppsValues() { if (!$this->installedAppsCache) { $values = $this->appConfig->getValues(false, 'enabled'); $alwaysEnabledApps = $this->getAlwaysEnabledApps(); foreach ($alwaysEnabledApps as $appId) { $values[$appId] = 'yes'; } $this->installedAppsCache = array_filter($values, function ($value) { return $value !== 'no'; }); ksort($this->installedAppsCache); } return $this->installedAppsCache; } /** * List all installed apps * * @return string[] */ public function getInstalledApps() { return array_keys($this->getInstalledAppsValues()); } /** * List all apps enabled for a user * * @param \OCP\IUser $user * @return string[] */ public function getEnabledAppsForUser(IUser $user) { $apps = $this->getInstalledAppsValues(); $appsForUser = array_filter($apps, function ($enabled) use ($user) { return $this->checkAppForUser($enabled, $user); }); return array_keys($appsForUser); } /** * @param \OCP\IGroup $group * @return array */ public function getEnabledAppsForGroup(IGroup $group): array { $apps = $this->getInstalledAppsValues(); $appsForGroups = array_filter($apps, function ($enabled) use ($group) { return $this->checkAppForGroups($enabled, $group); }); return array_keys($appsForGroups); } /** * @return array */ public function getAutoDisabledApps(): array { return $this->autoDisabledApps; } /** * @param string $appId * @return array */ public function getAppRestriction(string $appId): array { $values = $this->getInstalledAppsValues(); if (!isset($values[$appId])) { return []; } if ($values[$appId] === 'yes' || $values[$appId] === 'no') { return []; } return json_decode($values[$appId], true); } /** * Check if an app is enabled for user * * @param string $appId * @param \OCP\IUser $user (optional) if not defined, the currently logged in user will be used * @return bool */ public function isEnabledForUser($appId, $user = null) { if ($this->isAlwaysEnabled($appId)) { return true; } if ($user === null) { $user = $this->userSession->getUser(); } $installedApps = $this->getInstalledAppsValues(); if (isset($installedApps[$appId])) { return $this->checkAppForUser($installedApps[$appId], $user); } else { return false; } } /** * @param string $enabled * @param IUser $user * @return bool */ private function checkAppForUser($enabled, $user) { if ($enabled === 'yes') { return true; } elseif ($user === null) { return false; } else { if (empty($enabled)) { return false; } $groupIds = json_decode($enabled); if (!is_array($groupIds)) { $jsonError = json_last_error(); $this->logger->warning('AppManger::checkAppForUser - can\'t decode group IDs: ' . print_r($enabled, true) . ' - json error code: ' . $jsonError); return false; } $userGroups = $this->groupManager->getUserGroupIds($user); foreach ($userGroups as $groupId) { if (in_array($groupId, $groupIds, true)) { return true; } } return false; } } /** * @param string $enabled * @param IGroup $group * @return bool */ private function checkAppForGroups(string $enabled, IGroup $group): bool { if ($enabled === 'yes') { return true; } elseif ($group === null) { return false; } else { if (empty($enabled)) { return false; } $groupIds = json_decode($enabled); if (!is_array($groupIds)) { $jsonError = json_last_error(); $this->logger->warning('AppManger::checkAppForUser - can\'t decode group IDs: ' . print_r($enabled, true) . ' - json error code: ' . $jsonError); return false; } return in_array($group->getGID(), $groupIds); } } /** * Check if an app is enabled in the instance * * Notice: This actually checks if the app is enabled and not only if it is installed. * * @param string $appId * @param \OCP\IGroup[]|String[] $groups * @return bool */ public function isInstalled($appId) { $installedApps = $this->getInstalledAppsValues(); return isset($installedApps[$appId]); } public function ignoreNextcloudRequirementForApp(string $appId): void { $ignoreMaxApps = $this->config->getSystemValue('app_install_overwrite', []); if (!in_array($appId, $ignoreMaxApps, true)) { $ignoreMaxApps[] = $appId; $this->config->setSystemValue('app_install_overwrite', $ignoreMaxApps); } } /** * Enable an app for every user * * @param string $appId * @param bool $forceEnable * @throws AppPathNotFoundException */ public function enableApp(string $appId, bool $forceEnable = false): void { // Check if app exists $this->getAppPath($appId); if ($forceEnable) { $this->ignoreNextcloudRequirementForApp($appId); } $this->installedAppsCache[$appId] = 'yes'; $this->appConfig->setValue($appId, 'enabled', 'yes'); $this->dispatcher->dispatch(ManagerEvent::EVENT_APP_ENABLE, new ManagerEvent( ManagerEvent::EVENT_APP_ENABLE, $appId )); $this->clearAppsCache(); } /** * Whether a list of types contains a protected app type * * @param string[] $types * @return bool */ public function hasProtectedAppType($types) { if (empty($types)) { return false; } $protectedTypes = array_intersect($this->protectedAppTypes, $types); return !empty($protectedTypes); } /** * Enable an app only for specific groups * * @param string $appId * @param \OCP\IGroup[] $groups * @param bool $forceEnable * @throws \InvalidArgumentException if app can't be enabled for groups * @throws AppPathNotFoundException */ public function enableAppForGroups(string $appId, array $groups, bool $forceEnable = false): void { // Check if app exists $this->getAppPath($appId); $info = $this->getAppInfo($appId); if (!empty($info['types']) && $this->hasProtectedAppType($info['types'])) { throw new \InvalidArgumentException("$appId can't be enabled for groups."); } if ($forceEnable) { $this->ignoreNextcloudRequirementForApp($appId); } $groupIds = array_map(function ($group) { /** @var \OCP\IGroup $group */ return ($group instanceof IGroup) ? $group->getGID() : $group; }, $groups); $this->installedAppsCache[$appId] = json_encode($groupIds); $this->appConfig->setValue($appId, 'enabled', json_encode($groupIds)); $this->dispatcher->dispatch(ManagerEvent::EVENT_APP_ENABLE_FOR_GROUPS, new ManagerEvent( ManagerEvent::EVENT_APP_ENABLE_FOR_GROUPS, $appId, $groups )); $this->clearAppsCache(); } /** * Disable an app for every user * * @param string $appId * @param bool $automaticDisabled * @throws \Exception if app can't be disabled */ public function disableApp($appId, $automaticDisabled = false) { if ($this->isAlwaysEnabled($appId)) { throw new \Exception("$appId can't be disabled."); } if ($automaticDisabled) { $previousSetting = $this->appConfig->getValue($appId, 'enabled', 'yes'); if ($previousSetting !== 'yes' && $previousSetting !== 'no') { $previousSetting = json_decode($previousSetting, true); } $this->autoDisabledApps[$appId] = $previousSetting; } unset($this->installedAppsCache[$appId]); $this->appConfig->setValue($appId, 'enabled', 'no'); // run uninstall steps $appData = $this->getAppInfo($appId); if (!is_null($appData)) { \OC_App::executeRepairSteps($appId, $appData['repair-steps']['uninstall']); } $this->dispatcher->dispatch(ManagerEvent::EVENT_APP_DISABLE, new ManagerEvent( ManagerEvent::EVENT_APP_DISABLE, $appId )); $this->clearAppsCache(); } /** * Get the directory for the given app. * * @param string $appId * @return string * @throws AppPathNotFoundException if app folder can't be found */ public function getAppPath($appId) { $appPath = \OC_App::getAppPath($appId); if ($appPath === false) { throw new AppPathNotFoundException('Could not find path for ' . $appId); } return $appPath; } /** * Get the web path for the given app. * * @param string $appId * @return string * @throws AppPathNotFoundException if app path can't be found */ public function getAppWebPath(string $appId): string { $appWebPath = \OC_App::getAppWebPath($appId); if ($appWebPath === false) { throw new AppPathNotFoundException('Could not find web path for ' . $appId); } return $appWebPath; } /** * Clear the cached list of apps when enabling/disabling an app */ public function clearAppsCache() { $settingsMemCache = $this->memCacheFactory->createDistributed('settings'); $settingsMemCache->clear('listApps'); $this->appInfos = []; } /** * Returns a list of apps that need upgrade * * @param string $version Nextcloud version as array of version components * @return array list of app info from apps that need an upgrade * * @internal */ public function getAppsNeedingUpgrade($version) { $appsToUpgrade = []; $apps = $this->getInstalledApps(); foreach ($apps as $appId) { $appInfo = $this->getAppInfo($appId); $appDbVersion = $this->appConfig->getValue($appId, 'installed_version'); if ($appDbVersion && isset($appInfo['version']) && version_compare($appInfo['version'], $appDbVersion, '>') && \OC_App::isAppCompatible($version, $appInfo) ) { $appsToUpgrade[] = $appInfo; } } return $appsToUpgrade; } /** * Returns the app information from "appinfo/info.xml". * * @param string $appId app id * * @param bool $path * @param null $lang * @return array|null app info */ public function getAppInfo(string $appId, bool $path = false, $lang = null) { if ($path) { $file = $appId; } else { if ($lang === null && isset($this->appInfos[$appId])) { return $this->appInfos[$appId]; } try { $appPath = $this->getAppPath($appId); } catch (AppPathNotFoundException $e) { return null; } $file = $appPath . '/appinfo/info.xml'; } $parser = new InfoParser($this->memCacheFactory->createLocal('core.appinfo')); $data = $parser->parse($file); if (is_array($data)) { $data = \OC_App::parseAppInfo($data, $lang); } if ($lang === null) { $this->appInfos[$appId] = $data; } return $data; } public function getAppVersion(string $appId, bool $useCache = true): string { if (!$useCache || !isset($this->appVersions[$appId])) { $appInfo = $this->getAppInfo($appId); $this->appVersions[$appId] = ($appInfo !== null && isset($appInfo['version'])) ? $appInfo['version'] : '0'; } return $this->appVersions[$appId]; } /** * Returns a list of apps incompatible with the given version * * @param string $version Nextcloud version as array of version components * * @return array list of app info from incompatible apps * * @internal */ public function getIncompatibleApps(string $version): array { $apps = $this->getInstalledApps(); $incompatibleApps = []; foreach ($apps as $appId) { $info = $this->getAppInfo($appId); if ($info === null) { $incompatibleApps[] = ['id' => $appId, 'name' => $appId]; } elseif (!\OC_App::isAppCompatible($version, $info)) { $incompatibleApps[] = $info; } } return $incompatibleApps; } /** * @inheritdoc * In case you change this method, also change \OC\App\CodeChecker\InfoChecker::isShipped() */ public function isShipped($appId) { $this->loadShippedJson(); return in_array($appId, $this->shippedApps, true); } private function isAlwaysEnabled($appId) { $alwaysEnabled = $this->getAlwaysEnabledApps(); return in_array($appId, $alwaysEnabled, true); } /** * In case you change this method, also change \OC\App\CodeChecker\InfoChecker::loadShippedJson() * @throws \Exception */ private function loadShippedJson() { if ($this->shippedApps === null) { $shippedJson = \OC::$SERVERROOT . '/core/shipped.json'; if (!file_exists($shippedJson)) { throw new \Exception("File not found: $shippedJson"); } $content = json_decode(file_get_contents($shippedJson), true); $this->shippedApps = $content['shippedApps']; $this->alwaysEnabled = $content['alwaysEnabled']; $this->defaultEnabled = $content['defaultEnabled']; } } /** * @inheritdoc */ public function getAlwaysEnabledApps() { $this->loadShippedJson(); return $this->alwaysEnabled; } /** * @inheritdoc */ public function isDefaultEnabled(string $appId): bool { return (in_array($appId, $this->getDefaultEnabledApps())); } /** * @inheritdoc */ public function getDefaultEnabledApps():array { $this->loadShippedJson(); return $this->defaultEnabled; } } a> 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
--[[
Copyright (c) 2011-2015, Vsevolod Stakhov <vsevolod@highsecure.ru>

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
]]--

-- Phishing detection interface for selecting phished urls and inserting corresponding symbol
--
--
local symbol = 'PHISHED_URL'
local openphish_symbol = 'PHISHED_OPENPHISH'
local phishtank_symbol = 'PHISHED_PHISHTANK'
local domains = nil
local strict_domains = {}
local redirector_domains = {}
local openphish_map = 'https://www.openphish.com/feed.txt'
local phishtank_map = 'http://data.phishtank.com/data/online-valid.json'
-- Not enabled by default as their feed is quite large
local phishtank_enabled = false
local openphish_premium = false
local openphish_hash
local phishtank_hash
local openphish_json = {}
local phishtank_data = {}
local rspamd_logger = require "rspamd_logger"
local util = require "rspamd_util"
local opts = rspamd_config:get_all_opt('phishing')

local function phishing_cb(task)
  local urls = task:get_urls()

  if urls then
    for _,url in ipairs(urls) do
      if openphish_hash then
        local t = url:get_text()

        if openphish_premium then
          local elt = openphish_json[t]
          if elt then
            task:insert_result(openphish_symbol, 1.0, {
              elt['tld'],
              elt['sector'],
              elt['brand'],
            })
          end
        else
          if openphish_hash:get_key(t) then
            task:insert_result(openphish_symbol, 1.0, url:get_tld())
          end
        end
      end

      if phishtank_hash then
        local t = url:get_text()
        local elt = phishtank_data[t]
        if elt then
          task:insert_result(phishtank_symbol, 1.0, elt)
        end
      end

      if url:is_phished() and not url:is_redirected() then
        local found = false
        local purl = url:get_phished()
        local tld = url:get_tld()
        local ptld = purl:get_tld()

        if not ptld or not tld then
          return
        end

        local weight = 1.0
        local dist = util.levenshtein_distance(tld, ptld, 2)
        dist = 2 * dist / (#tld + #ptld)

        if dist > 0.3 and dist <= 1.0 then
          -- Use distance to penalize the total weight
          weight = util.tanh(3 * (1 - dist + 0.1))
        end
        rspamd_logger.debugx(task, "distance: %1 -> %2: %3", tld, ptld, dist)

        if #redirector_domains > 0 then
          for _,rule in ipairs(redirector_domains) do
            if rule['map']:get_key(url:get_tld()) then
              task:insert_result(rule['symbol'], weight, ptld .. '->' .. tld)
              found = true
            end
          end
        end
        if not found and #strict_domains > 0 then
          for _,rule in ipairs(strict_domains) do
            if rule['map']:get_key(ptld) then
              task:insert_result(rule['symbol'], 1.0, ptld .. '->' .. tld)
              found = true
            end
          end
        end
        if not found then
          if domains then
            if domains:get_key(ptld) then
              task:insert_result(symbol, weight, ptld .. '->' .. tld)
            end
          else
            task:insert_result(symbol, weight, ptld .. '->' .. tld)
          end
        end
      end
    end
  end
end

local function phishing_map(mapname, phishmap)
  if opts[mapname] then
    local xd = {}
    if type(opts[mapname]) == 'table' then
      xd = opts[mapname]
    else
      xd[1] = opts[mapname]
    end
    for _,d in ipairs(xd) do
      local s, _ = string.find(d, ':[^:]+$')
      if s then
        local sym = string.sub(d, s + 1, -1)
        local map = string.sub(d, 1, s - 1)
        rspamd_config:register_virtual_symbol(sym, 1, id)
        local rmap = rspamd_config:add_map ({
          type = 'set',
          url = map,
          description = 'Phishing ' .. mapname .. ' map',
        })
        if rmap then
          local rule = {symbol = sym, map = rmap}
          table.insert(phishmap, rule)
        else
          rspamd_logger.infox(rspamd_config, 'cannot add map: ' .. map .. ' for symbol: ' .. sym)
        end
      else
        rspamd_logger.infox(rspamd_config, mapname .. ' option must be in format <map>:<symbol>')
      end
    end
  end
end

local function rspamd_str_split_fun(s, sep, func)
  local lpeg = require "lpeg"
  sep = lpeg.P(sep)
  local elem = lpeg.C((1 - sep)^0 / func)
  local p = lpeg.C(elem * (sep * elem)^0)   -- make a table capture
  return lpeg.match(p, s)
end

local function openphish_json_cb(string)
  local ucl = require "ucl"
  local nelts = 0
  local new_json_map = {}
  local valid = true

  local function openphish_elt_parser(cap)
    if valid then
      local parser = ucl.parser()
      local res,err = parser:parse_string(cap)
      if not res then
        valid = false
        rspamd_logger.warnx(rspamd_config, 'cannot parse openphish map: ' .. err)
      else
        local obj = parser:get_object()

        if obj['url'] then
          new_json_map[obj['url']] = obj
          nelts = nelts + 1
        end
      end
    end
  end

  rspamd_str_split_fun(string, '\n', openphish_elt_parser)

  if valid then
    openphish_json = new_json_map
    rspamd_logger.infox(openphish_hash, "parsed %s elements from openphish feed",
      nelts)
  end
end

local function phishtank_json_cb(string)
  local ucl = require "ucl"
  local nelts = 0
  local new_data = {}
  local valid = true
  local parser = ucl.parser()
  local res,err = parser:parse_string(string)

  if not res then
    valid = false
    rspamd_logger.warnx(rspamd_config, 'cannot parse openphish map: ' .. err)
  else
    local obj = parser:get_object()

    for _,elt in ipairs(obj) do
      if elt['url'] then
        new_data[elt['url']] = elt['phish_detail_url']
        nelts = nelts + 1
      end
    end
  end

  if valid then
    phishtank_data = new_data
    rspamd_logger.infox(phishtank_hash, "parsed %s elements from phishtank feed",
      nelts)
  end
end

if opts then
  if opts['symbol'] then
    symbol = opts['symbol']
    -- Register symbol's callback
    local id = rspamd_config:register_symbol({
      name = symbol,
      callback = phishing_cb
    })

    if opts['openphish_map'] then
      openphish_map = opts['openphish_map']
    end
    if opts['openphish_url'] then
      openphish_map = opts['openphish_url']
    end

    if opts['openphish_premium'] then
      openphish_premium = true
    end

    if not openphish_premium then
      openphish_hash = rspamd_config:add_map({
        type = 'set',
        url = openphish_map,
        description = 'Open phishing feed map (see https://www.openphish.com for details)'
      })
    else
      openphish_hash = rspamd_config:add_map({
          type = 'callback',
          url = openphish_map,
          callback = openphish_json_cb,
          description = 'Open phishing premium feed map (see https://www.openphish.com for details)'
        })
    end

    if opts['phihtank_map'] then
      phishtank_map = opts['phihtank_map']
    end
    if opts['phihtank_url'] then
      phishtank_map = opts['phihtank_url']
    end

    if opts['phishtank_enabled'] then
      phishtank_hash = rspamd_config:add_map({
          type = 'callback',
          url = phishtank_map,
          callback = phishtank_json_cb,
          description = 'Phishtank feed (see https://www.phishtank.com for details)'
        })
    end

    rspamd_config:register_symbol({
      type = 'virtual',
      parent = id,
      name = openphish_symbol,
    })

    rspamd_config:register_symbol({
      type = 'virtual',
      parent = id,
      name = phishtank_symbol,
    })
  end
  if opts['domains'] and type(opt['domains']) == 'string' then
    domains = rspamd_config:add_map({
      url = opts['domains'],
      type = 'set',
      description = 'Phishing domains'
    })
  end
  phishing_map('strict_domains', strict_domains)
  phishing_map('redirector_domains', redirector_domains)
end