# BookStack: 304 tests pass, and 269 fail as they must when sent as the wrong user

featkpr ran on a throwaway copy of BookStack at the head of one of its pull requests. It read the code, walked the screens, wrote tests per route and role, and sent each test again as the wrong user.

BookStack · commit 0f5164ec, the head of a pull request · run 26 Sep 2026, 17:42 UTC · wrong-user run 26 Sep 2026, 17:57 UTC · by Tim Derzhavets

[Ask for a private demo](https://featkpr.com/library/bookstack/demo) Tim opens this run in featkpr with you: any test, both of its runs.

**218** expect a refusal: sent as the Admin, who got through, so each failed as it must **51** expect to get through: sent as a user without the permission, refused, so each failed as it must **35** unchecked: none of our seed's users lacks the permission they need

269 of 304 proven by the wrong-user check · run 26 Sep 2026, 17:42 UTC, all 304 passed · wrong-user run 17:57 UTC · 0 got through when they should not have · 0 failed · commit 0f5164ec

**What worked**

All 304 tests that ran passed. Sent again as the wrong user, 269 of them failed, as they must: 51 refused to a user without the permission, 218 let through for the Admin. So each depends on a permission check BookStack makes.

**What is open**

35 tests are unchecked: none of our seed's users lacks the permission they need. 161 drafted flows wait for a person. No bug was caught.

**What it missed**

A separate missed-items check, from a fresh start: of 2,372 things, 605 missed at their own step, counted by step and by reason.

**The code:** [BookStack pull request #6213](https://codeberg.org/bookstack/bookstack/pulls/6213) , the head of the pull request on 24 Sep 2026, commit 0f5164ec, 73 commits after v26.05.5. Codeberg, read 27 Sep 2026

## How it was run

featkpr worked on a throwaway copy of BookStack at commit 0f5164ec: the head of BookStack [pull request #6213](https://codeberg.org/bookstack/bookstack/pulls/6213) , “Hide image upload options in Image Manager when user lacks permission”, merged with the development branch on 24 Sep 2026, 73 commits after the v26.05.5 release. The copy runs on PHP’s built-in server with MySQL 8.4 and a mail catcher, started empty on our own machines and thrown away after. It is not a production-like setup: no queue worker and no scheduler run. Nothing ran against BookStack’s servers or anyone’s data.

BookStack ships four roles: Admin, Editor, Viewer and Public. Our seed added four more, with one user in every role that signs in, so that for most permissions some user holds it and some user does not.

The six steps below ran on different days, and each prints its own date. The features, the tests, the run and the missed-items check are featkpr’s records, exported 28 Sep 2026. The crawl was read from the app on 25 Sep 2026, and the flows from its Flows board on 26 Sep 2026.

**BookStack's code** the head of pull request #6213, 24 Sep 2026 · commit 0f5164ec PHP · Laravel, read as files: nothing runs yet

- reads routes, handlers, permission checks **1 · code read** 343 routes
- cites the code each name came from **2 · features named** 307 features
- **3 · screens crawled** 167 screens signs in as the Admin, opens what it can reach, sends 8 create forms
- **4 · flows drafted** 163 flows
- **5 · tests run** 304 pass sends each test as the user it was written for
- **6 · wrong-user check** 269 proven sends it again as a user who should get the opposite answer

**A throwaway copy of BookStack** PHP's built-in server · MySQL 8.4 · a mail catcher, on our machines; no queue worker, no scheduler

- Admin
- Editor
- Viewer
- Public
- M6 Updater
- M6 Deleter
- M6 Reader
- M6 Content-only reader

one user per role; outlined thin: added by our seed

featkpr's steps in the middle; what each one reads or signs in to, at the side. Source: featkpr's setup for BookStack: its roles, its users and the images of the copy.

| Role | Signs in as | May |

|---|---|---|

| Admin BookStack's own | M6 Admin | everything, settings included |

| Editor BookStack's own | M6 Editor | create and edit books, chapters and pages |

| Viewer BookStack's own | M6 Viewer | read books and their content |

| Public BookStack's own | a signed-out visitor | what public access allows; it is off in this copy |

| M6 Updater added by our seed | M6 Updater | read pages and update them, no revisions |

| M6 Deleter added by our seed | M6 Deleter | read pages and delete them, no revisions |

| M6 Reader added by our seed | M6 Reader | read pages, no revisions |

| M6 Content-only reader added by our seed | M6 Content Only | read one book through a permission set on that book, nothing at role level |

BookStack ships four roles: Admin, Editor, Viewer and Public. Our seed added four more, and one user in every role that can sign in, so that for most permissions some user holds it and some user does not. “M6” is only our seed's prefix for what it made.

**BookStack**

the head of pull request #6213, commit 0f5164ec, on PHP's built-in server the code as of 24 Sep 2026, 73 commits after v26.05.5

**Its database**

MySQL 8.4, empty at the start and thrown away after the official MySQL 8.4 image

**Its mail**

a mail catcher, so nothing leaves the copy Mailpit

**What it is not**

a production-like setup: no queue worker and no scheduler run, so anything BookStack does later, in the background, was not exercised a throwaway copy for tests, not a staging server

**Where**

our own machines, one copy per commit; never BookStack's servers or anyone's data featkpr's setup for BookStack

[(picture: GET /books/{bookSlug}/permissions: the book's own permissions: the Content-only reader may view this one book and nothing more)](https://featkpr.com/img/app/bookstack-crawl-permissions.webp)

GET /books/{bookSlug}/permissions · the book's own permissions: the Content-only reader may view this one book and nothing more · BookStack, as the crawl captured it

## 1. Reading the code

featkpr read BookStack’s routes, their handlers and the permission checks written in them, at one commit: 343 routes. Nothing runs at this step. The missed-items section checks the reading: featkpr does not read BookStack’s console commands yet.

[(picture: Map, level 1: routes read from the code, grouped by what they do, before a feature names them)](https://featkpr.com/img/bk/routes-light.webp)

Map, level 1 · routes read from the code, grouped by what they do, before a feature names them

Screens of featkpr's app on this page run on sample data, so the numbers inside them are examples, not BookStack's. Screens of BookStack are the crawl's own captures.

## 2. Naming the features

From the routes and what their pages show, featkpr named 307 features in 12 modules (featkpr’s records, exported 28 Sep 2026). Each feature cites the routes and screens it was named from, so a name can always be traced back to code.

[(picture: Features: named features by module, from only known in the code to tests drafted)](https://featkpr.com/img/bk/features-rows-light.webp)

Features · named features by module, from only known in the code to tests drafted

## 3. Walking the screens

On 25 Sep 2026, featkpr signed in to the copy as the Admin and walked it: 167 screens opened, each kept as a picture, and 8 forms sent (read from the app on 25 Sep 2026). The crawl sends only forms that create something, so most edit and delete goals are not reached yet. It did not walk as a signed-out visitor.

[(picture: GET /: BookStack's home page)](https://featkpr.com/img/bs-home.webp)

GET / · BookStack's home page · BookStack, as the crawl captured it

[(picture: GET /create-book: the Create New Book form)](https://featkpr.com/img/bs-create.webp)

GET /create-book · the Create New Book form · BookStack, as the crawl captured it

[(picture: GET /shelves: BookStack's shelves)](https://featkpr.com/img/app/bookstack-crawl-shelves.webp)

GET /shelves · BookStack's shelves · BookStack, as the crawl captured it

[(picture: GET /books/{bookSlug}/page/{pageSlug}/revisions: a page's revisions)](https://featkpr.com/img/app/bookstack-crawl-revisions.webp)

GET /books/{bookSlug}/page/{pageSlug}/revisions · a page's revisions · BookStack, as the crawl captured it

Four of the 167 screens, as the crawl captured them signed in as the Admin on the copy. The books and pages in them are the copy's own seed. Each opens full size.

## 4. Drafting the flows

From the crawl, featkpr drafted 163 flows a person can take to reach 69 goals, screen by screen, with what a person does on each screen and what a test would check (the Flows board, read on 26 Sep 2026). A person keeps or drops each one. 161 are still waiting, so no test has been written from a flow yet.

**Create a book** a goal featkpr drafted, starting from Home BookStack · commit 0f5164ec · crawl of 25 Sep 2026, read from the app · signed in as Admin

1 · GET /

[(picture: BookStack's home page as the crawl captured it)](https://featkpr.com/img/bs-home.webp)

Start on the home page

- Follow “Books”
- lands on /books
- its heading reads “Books”

screen captured by the crawl

2 · GET /books

[(picture: BookStack's books list)](https://featkpr.com/img/bs-books.webp)

Open the new-book form

- Follow “Create New Book”
- lands on /create-book
- its heading reads “Create New Book”

screen captured by the crawl

3 · GET /create-book

[(picture: BookStack's Create New Book form)](https://featkpr.com/img/bs-create.webp)

Fill the form and save

- Type the Name required
- Description optional
- Cover image optional
- Book Tags optional
- Press “Save Book”
- lands on /books/{slug}
- its heading reads the Name typed
- it shows the Name typed
- without a Name it is refused: The name field is required.

form filled and sent by the crawl

4 · GET /books/{slug}

[(picture: A BookStack book page)](https://featkpr.com/img/bs-book.webp)

The new book's page

- reaches /books/{slug}
- the flow ends here, not before

a book's page, captured by the crawl

then · a person

Waiting on a person

Keep it and its 10 checks become the flow's test. Drop it and it is never offered again.

tests from this flow: none yet

## 5. Writing and running the tests

featkpr writes its tests from the map, per route and per role: 481 written so far (27 Sep), none by hand. Each is a pytest file that calls BookStack through Playwright’s request API, with no browser. The run of 26 Sep 2026, 17:42 UTC ran the 304 that were ready then, each signed in as the user it was written for: someone who should get through, or someone who should be refused. All 304 passed.

[(picture: a feature's outcomes: who gets what on one feature, with and without each permission; each line becomes a test)](https://featkpr.com/img/app/perm-leaves-light.webp)

a feature's outcomes · who gets what on one feature, with and without each permission; each line becomes a test

Each line is one outcome of one feature, with and without a permission; each outcome becomes one test. The app on sample data.

## 6. The wrong-user check

This is the access-control part of a penetration test, the part that finds broken access control, OWASP’s number one risk on the web. It runs per route and role, which makes it narrower than a full penetration test.

A passing test shows a goal works, but a route that checks no permission at all passes too. So featkpr sends each test a second time as the wrong user: someone who should get the opposite answer. A test that expects to get through is sent as a user without the permission, and BookStack must refuse. A test that expects a refusal is sent as the Admin, who must get through. Either way the test must now fail. A test that still passes is not checking the permission.

In the wrong-user run of 26 Sep 2026, 17:57 UTC, 269 of the 304 tests failed as the wrong user, as they must: 51 were refused to a user without the permission, and 218 were let through for the Admin. None got through as the wrong user when it should not have. 35 are unchecked: they all expect access, and the wrong-user run signs in only as users our seed made; none of them lacks the permission.

One test · Entities

Convert a book into a shelf

POST /books/m6-revisions/convert-to-shelf

- Sign in as the test's own user.
- Convert a book of the test's own into a shelf: send the form straight to the route, with the page's CSRF token.
- Check: BookStack answers 302 and moves on to /shelves/{slug}.

Sent twice, the same request. Only the signed-in user changes.

The test's own user · the run

**M6 Editor** , role Editor

`book-create-all` `book-delete` `book-update` `book-view` `bookshelf-create-all`

BookStack answers **302 → /shelves/m6-revisions**

passed: the goal works for someone allowed to reach it

The wrong user · the wrong-user run

**M6 Content Only** , role M6 Content-only reader

`content-export` and nothing more: no book-update, bookshelf-create-all

BookStack sent to / (the home page) instead of to the new shelf

refused, so the test fails, as it must: the test's pass depends on BookStack's permission check

the test's own words

REACHED redirects to /shelves/m6-revisions; this went to 'http://localhost:8099'localhost:8099 is the throwaway copy's own address, so this is its home page

This test expects to get through, so its wrong user is someone without the permission. It is one of 51 like it in the run. The other 218 proven tests expect a refusal, so their wrong user is the Admin, who must get through, and the test fails the same way.

One test as featkpr's app shows it, on sample data recorded at commit 0f5164ec, not the run of 26 Sep · Entities · POST /books/{bookSlug}/convert-to-shelf

### Every test of the run, accounted for

as the wrong user it **failed, as it must** as the wrong user it **still passed** there is **no wrong user** to send yet **passed** for its own user failed as the wrong user, as it must **269** proven: 218 let through for the Admin when they expect a refusal, 51 refused to a user without the permission when they expect to get through still passed as the wrong user **0** would mean a missing permission check, or a test that cannot tell no wrong user yet **35** unchecked: they all expect access, and the wrong-user run signs in only as users our seed made; none of them lacks the permission **failed** for its own user **0** a test that fails for its own user says nothing about the wrong one

BookStack · the run of 26 Sep 2026, 17:42 UTC and its wrong-user run at 17:57 UTC · all 304 tests · commit 0f5164ec · featkpr's records

See the wrong-user run for yourself, on a call

Tim opens this run in featkpr: any of the 304 tests, both of its runs, and why it passed or failed.

[Ask for a private demo](https://featkpr.com/library/bookstack/demo)

## What featkpr missed

This is a separate check, run on 26 Sep 2026. featkpr onboarded BookStack again from a fresh start with no help, on a replay of the admin crawl, and compared each step with an answer key: the route list Laravel prints, the forms in BookStack’s templates, what the seed creates, and so on. For routes the key is the same list the reader uses, so that row counts what was dropped; for every other step the key is independent of featkpr. Of 2,372 things in the key, featkpr gathered 1,437, missed 605 at the step that should have found them, and lost the rest because an earlier step missed them. The misses are counted by step and by reason.

Most misses are of two kinds: a reader that did not see something, and a limit we set on purpose for now, such as sending only create forms. A thing an early step misses is also lost to every step after it, which is why a few early gaps cost the later steps so much.

Step, checked against gathered · missed at this step · lost at an earlier step Commonest reason, with one real case **Routes read** the route list Laravel prints **343** of 359 · 16 missed **not built yet** BookStack's console commands, such as bookstack:assign-sort-rule: no part of featkpr reads commands a person runs yet.

the audit's own words

`bookstack:assign-sort-rule` · `not built` : no adapter reads console commands: an entry point a person runs is never a candidate **Permission checks found** checks in each handler and route middleware **193** of 194 · 1 missed **a reader missed it** PUT /api/users/{id} checks its permission with a name built while it runs, so there is no name in the code to read.

the audit's own words

`PUT /api/users/{id}` · `detector gap` : the handler calls userCan with a computed name (no literal to read); the pass stored no presence **Routes a feature cites** every route, against what the naming said **356** of 587 · 231 missed **a reader missed it** DELETE /ajax/page/{id}: no named feature claims this route, and nothing on its page links it to one.

the audit's own words

`DELETE /ajax/page/{id}` · `detector gap` : no reply cites the route, and contains joins it to nothing a feature cites (no view or string of its page) **Pages reached** every GET route **96** of 198 · 102 missed **a reader missed it** GET /ajax/tags/suggest/names: the crawl reached it, but it is not a page, and featkpr kept no record that it was reached.

the audit's own words

`GET /ajax/tags/suggest/names` · `detector gap` : the crawl reached it (other: not an HTML page) and no stored fact says so; only the crawl report holds it **Screens captured** GET routes that draw a page **77** of 137 · 5 missed · 55 lost earlier **a limit we set** GET /api-tokens/{userId}/create: the crawl skips API-token pages on purpose.

the audit's own words

`GET /api-tokens/{userId}/create` · `cap` : the policy's no_shots list **Forms found** the templates' forms **62** of 93 · 19 missed · 12 lost earlier **a reader missed it** The image manager's delete form: a script sets where it sends, so the template names no route to join it to.

the audit's own words

`DELETE @resources/views/pages/parts/image-manager-form.blade.php:84` · `detector gap` : its action is not a URL the template spells (set by script, or a variable): no route to join **Forms sent** the forms that change something **4** of 87 · 52 missed · 31 lost earlier **a limit we set** DELETE /api-tokens/{userId}/{tokenId}: the crawl sends only forms that create something, and this one deletes.

the audit's own words

`DELETE /api-tokens/{userId}/{tokenId}` · `cap` : not on the policy's create-only allow-list (submit fills only allow-listed forms) **Flows found** entry to target, per role **141** of 191 · 24 missed · 26 lost earlier **a limit we set** From the home page to a chapter: every way there runs through records the crawl made itself, and those flows are left out on purpose.

the audit's own words

`GET / → GET /books/{bookSlug}/chapter/{chapterSlug}` · `cap` : every path passes through what the crawl made itself (a page carrying a submission's marker, or the record a writing GET made): held by 'flows clean' **Outcomes per feature** who gets what, per permission **86** of 404 · 112 missed · 206 lost earlier **a limit we set** GET /: no named feature starts at the home page, so nobody's outcomes on it were drafted.

the audit's own words

`GET /` · `cap` : one tree per named feature, from its root: the naming cites the route (or what its page shows) and roots no feature's tree here, nor at an atom its gate reads **Tests written** one per stored outcome **76** of 86 · 10 missed **not built yet** DELETE /settings/users/{id}/mfa, the refused case: this route checks no permission, and every test template expects one.

the audit's own words

`DELETE /settings/users/{id}/mfa · refused` · `not built` : an ungated leaf on DELETE /settings/users/{id}/mfa: every template declares gated shapes only **Seeded records seen** what the seed creates **3** of 9 · 6 missed **a limit we set** The seeded user “M6 Content Only”: the crawl found its page, and a cap on pages of one pattern left it out.

the audit's own words

`the seeded user 'M6 Content Only' (M6 Content-only reader)` · `cap` : the crawl found /user/m6-content-only and the per-pattern cap left it out **Effects seen** mail, downloads, off-host links **0** of 27 · 27 missed **a reader missed it** GET /api/docs/download: the crawl downloaded the file, and kept its type, size and hash only in its own report.

the audit's own words

`/api/docs/download` · `detector gap` : the crawl downloaded it; its type, size and hash stay in the crawl report

BookStack · the missed-items check of 26 Sep 2026, exported · commit 0f5164ec · a replay of the admin crawl · of 2,372 things: 1,437 gathered, 605 missed at their own step, 330 lost at an earlier step · of the 605, 592 are featkpr’s own gaps; the audit puts 13 down to BookStack’s side (pages only a signed-out visitor sees, forms nothing links to)

A miss costs the steps after it

**Pages reached** 102 missed here 55 lost **Screens captured** 77 of 137 12 lost **Forms found** 62 of 93 **Forms found** 19 missed here 31 lost **Forms sent** 4 of 87 26 lost **Flows found** 141 of 191 **Routes a feature cites** 231 missed here 206 lost **Outcomes per feature** 86 of 404

each thing is counted missed once, at the step that lost it, and as lost earlier at every later step that needed it

Why, across all steps

- **295** **a reader missed it** the step ran, and its reader did not see this one
- **199** **a limit we set** left out by a cap or an allow-list, on purpose for now
- **67** **not built yet** no part of featkpr reads this kind of thing yet
- **28** **needed a record first** the page needs an id no crawled page linked to
- **10** **needed another user** only a signed-out visitor sees it, and no signed-out crawl ran
- **3** **nothing links to it** no page renders it by a name the source spells
- **3** **kept out on purpose** an off-host address the crawl never follows

605 missed things by reason · “a limit we set” is a cap or an allow-list chosen on purpose for now

Every step against every reason

a reader missed it a limit we set not built yet needed a record first needed another user nothing links to it kept out on purpose lost earlier Routes read **16** Permission checks found **1** Routes a feature cites **231** Pages reached **38** **33** **28** **3** Screens captured **5** 55 Forms found **9** **7** **3** 12 Forms sent **52** 31 Flows found **24** 26 Outcomes per feature **112** 206 Tests written **4** **6** Seeded records seen **6** Effects seen **12** **12** **3** all steps **295** **199** **67** **28** **10** **3** **3**

## Why these counts differ from the totals

The missed-items check is a smaller, separate run: from a fresh start, on a replay of the crawl, with no running copy. So its counts are not the totals above, and some count a different unit. The table puts the two side by side.

| Quantity | In the totals | In the missed-items check | Why they differ |

|---|---|---|---|

| Routes | **343 routes** read from the code, in the check of 26 Sep 2026 | **343 of 359** the check of 26 Sep 2026, exported | the same count: the totals take their routes from this check’s first step |

| Features | **307 features** featkpr's records, exported 28 Sep 2026 | **356 of 587** the check of 26 Sep 2026, exported | a different unit: the check counts routes a named feature cites, and it names with a recorded naming run |

| Screens | **167 screens** the crawl of 25 Sep 2026, read from the app | **77 of 137** the check of 26 Sep 2026, exported | the crawl keeps a picture per visit; the check counts the distinct routes that draw a page, on a replay of a recorded crawl |

| Forms sent | **8 forms** the crawl of 25 Sep 2026, read from the app | **4 of 87** the check of 26 Sep 2026, exported | two different crawls: the replayed one sends only the forms on a create-only allow-list |

| Tests | **481 written, 304 run** featkpr's records, exported 28 Sep 2026 | **76 of 86** the check of 26 Sep 2026, exported | the check starts from nothing, so it holds only the outcomes it drafted itself |

| The run | **269 proven by the wrong-user check** the run of 26 Sep 2026, 17:42 UTC | **not run** the check of 26 Sep 2026, exported | the check needs no running copy, so running the tests and the wrong-user check sit outside it |

## What this report does not show

It shows no caught bug. Every test passed at this commit. The wrong-user check shows that 269 tests would catch their permission check going missing; none has caught one yet.

The tests call BookStack directly, without a browser. Browser tests of the drafted flows come after a person keeps them. The crawl signed in as the Admin only, and sent only create forms. The copy ran no queue worker and no scheduler. The 35 unchecked tests need a user our seed has not made yet: one without their permission.

Shown, with its evidence

- the 304 tests that ran all pass for their own user
- 269 of them fail as the wrong user, as they must
- 605 misses, counted by the step that lost them and why
- every screen, test and count dated and tied to commit 0f5164ec

Not shown yet

- a caught bug: every test passed at the commit we ran
- tests in a browser: these call BookStack directly
- the 35 unchecked tests: they need a user without the permission
- a signed-out crawl, forms that edit or delete, and a production-like copy

## How to check it yourself

The wrong-user test in the figure needs nothing of ours. On your own copy of BookStack at commit [0f5164ec](https://codeberg.org/bookstack/bookstack/commit/0f5164ec3ae35822d1abd0e520b6f1406c94c76a) : make a role that holds only content-export, give it view access to one book through that book’s own permissions, and sign in as a user in that role. Send the book’s Convert to Shelf form (POST /books/{slug}/convert-to-shelf, with the page’s CSRF token). BookStack sends you to the home page. Sign in as an Editor and send the same form: BookStack answers 302 and moves on to the new shelf.

Every other figure names its source under it: the run and its commit, the missed-items check, or the screen it was read from. Ask on a call and we walk through any of them.

## Who made it

Tim Derzhavets ran featkpr and wrote this report, following [How we publish](https://featkpr.com/findings/policy) (draft of 27 Sep 2026). Anything that passed as the wrong user would be looked at by a person before it was published, and a suspected bug would go to BookStack’s maintainers privately first. featkpr is not affiliated with BookStack. Ask for a correction or a removal at [/removal](https://featkpr.com/removal) .

## See this run live, on a call

Tim walks you through BookStack in featkpr, the map, the flows and the runs, and answers your questions first.

[Ask for a private demo](https://featkpr.com/library/bookstack/demo)

---

The page this twin stands for: https://featkpr.com/library/bookstack/reports/2026-09-26-bookstack-run. Every page on this site has a `.md` twin, and answers `Accept: text/markdown`.
