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 demoTim opens this run in featkpr with you: any test, both of its runs.
- 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, 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, “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.
- reads routes, handlers, permission checks 1 · code read343 routes
- cites the code each name came from 2 · features named307 features
- 3 · screens crawled167 screens signs in as the Admin, opens what it can reach, sends 8 create forms
- 4 · flows drafted163 flows
- 5 · tests run304 pass sends each test as the user it was written for
- 6 · wrong-user check269 proven sends it again as a user who should get the opposite answer
- Admin
- Editor
- Viewer
- Public
- M6 Updater
- M6 Deleter
- M6 Reader
- M6 Content-only reader
| Role | Signs in as | May |
|---|---|---|
| AdminBookStack's own | M6 Admin | everything, settings included |
| EditorBookStack's own | M6 Editor | create and edit books, chapters and pages |
| ViewerBookStack's own | M6 Viewer | read books and their content |
| PublicBookStack's own | a signed-out visitor | what public access allows; it is off in this copy |
| M6 Updateradded by our seed | M6 Updater | read pages and update them, no revisions |
| M6 Deleteradded by our seed | M6 Deleter | read pages and delete them, no revisions |
| M6 Readeradded by our seed | M6 Reader | read pages, no revisions |
| M6 Content-only readeradded 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 serverthe code as of 24 Sep 2026, 73 commits after v26.05.5
- Its database
- MySQL 8.4, empty at the start and thrown away afterthe official MySQL 8.4 image
- Its mail
- a mail catcher, so nothing leaves the copyMailpit
- 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 exerciseda throwaway copy for tests, not a staging server
- Where
- our own machines, one copy per commit; never BookStack's servers or anyone's datafeatkpr's setup for BookStack

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.
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.
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.




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.
-
1 · GET /

Start on the home page
- Follow “Books”
- lands on /books
- its heading reads “Books”
screen captured by the crawl
-
2 · GET /books

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

Fill the form and save
- Type the Namerequired
- Descriptionoptional
- Cover imageoptional
- Book Tagsoptional
- 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}

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.
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-allbook-deletebook-updatebook-viewbookshelf-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.
Every test of the run, accounted for #
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.
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.
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 candidatethe 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 presencethe 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)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 itthe audit's own words
GET /api-tokens/{userId}/create · cap: the policy's no_shots listthe 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 jointhe 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)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'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 readsthe 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 onlythe 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 outthe audit's own words
/api/docs/download · detector gap: the crawl downloaded it; its type, size and hash stay in the crawl reportA miss costs the steps after it
Why, across all steps
- 295a reader missed it the step ran, and its reader did not see this one
- 199a limit we set left out by a cap or an allow-list, on purpose for now
- 67not built yet no part of featkpr reads this kind of thing yet
- 28needed a record first the page needs an id no crawled page linked to
- 10needed another user only a signed-out visitor sees it, and no signed-out crawl ran
- 3nothing links to it no page renders it by a name the source spells
- 3kept out on purpose an off-host address the crawl never follows
Every step against every reason
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 routesread from the code, in the check of 26 Sep 2026 | 343 of 359the check of 26 Sep 2026, exported | the same count: the totals take their routes from this check’s first step |
| Features | 307 featuresfeatkpr's records, exported 28 Sep 2026 | 356 of 587the 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 screensthe crawl of 25 Sep 2026, read from the app | 77 of 137the 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 formsthe crawl of 25 Sep 2026, read from the app | 4 of 87the 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 runfeatkpr's records, exported 28 Sep 2026 | 76 of 86the 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 checkthe run of 26 Sep 2026, 17:42 UTC | not runthe 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: 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 (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.
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.


