Skip to content

Korivi security v1 - #2

Merged
CraftOS-dev merged 7 commits into
mainfrom
korivi-Security-V1
Sep 10, 2026
Merged

CraftOS-dev merged 7 commits into
mainfrom
korivi-Security-V1

Conversation

@korivi-CraftOS

Copy link
Copy Markdown
Collaborator

No description provided.

The guard is keyed by names taken straight from the request body, and it
probed two plain objects with them. `allow["constructor"]` and
`map["toString"]` are truthy on any plain object, so a field named after
an Object.prototype member skipped the allow check, then read as
declared, then passed every rule after it — no unknown_field, no type
check, no enum check, no read-only check. The key went on to the
backend.

Confirmed against a running app: `bogus` was rejected, while
`constructor`, `toString`, `hasOwnProperty`, `valueOf` and `__proto__`
were all accepted with no violation at all.

This is the one guarantee the rules layer exists to make ("no silent
200s"), voided by nothing more than the field's name, in the path shared
by every adapter — and, since operation arguments now run through the
same rules, by every declared operation too.

Make the lookups own-property tests and give the field map a null
prototype. Object.prototype pollution itself was not reachable: JSON.parse
makes __proto__ an ordinary own property, and neither spread nor
Object.assign writes through it.
`server.listen(PORT, cb)` binds every interface. Three shipped servers
did that while printing a startup line that said 127.0.0.1, including
the blueprint template that every scaffolded UI app runs.

That matters more than a wrong log line, because a same-origin request
is trusted as the owner without any credential, and the origins those
apps allow are their own guessable localhost URLs. The two together made
a scaffolded Agent App readable and writable by anyone who could reach
the port, with no token:

  curl -X POST http://<lan-ip>:8090/api/collections/tasks/records \
       -H 'Origin: http://127.0.0.1:8090' -d '{"title":"..."}'   -> 200

Verified against a real scaffolded app before the change (bound
0.0.0.0, wrote a record over the LAN address) and after (binds
127.0.0.1, remote connection refused, loopback unaffected). The reverse
proxy mattered most: it carries an owner-scoped grant with scopes ["*"].

The README already promises v0.1 is loopback-trust only. This makes the
code keep that promise; A2APP_HOST is there so exposing an app stays a
deliberate act.
assertInside compares strings, which is not the question a write needs
answered. A path with no `..` and no drive letter is "inside" by that
test even when a component of it is a link pointing somewhere else, and
an app directory is untrusted input: `import` exists to take one from a
stranger, and tar, zip and git all carry links — a Windows junction
needs no privilege at all.

So a shipped app carrying `hooks -> ~/.ssh`, plus a toolkit declaring
`hooks/authorized_keys` among its system paths, had that file vendored
straight through the link. Reproduced here: assertInside answered
"contained" and the write replaced a file outside the project.

vendorPaths now resolves containment physically — every component below
the project is checked, while the project directory itself may still be
a link, since the user chose where it lives. copyTree also replaces a
link it finds at a destination rather than writing through it, so a link
left in the tree cannot redirect a later sync.

The lexical check keeps its own name and behaviour; it is still the right
test for a path that is never opened.
Binding to loopback does not keep a browser out. A page on evil.com whose
DNS re-resolves to 127.0.0.1 is, to the browser, same-origin with this
server: it sends no Origin on a GET, so the origin rule has nothing to
judge, and on a single-user app an anonymous caller already reads
everything. The page walks describe, the records API, events and tasks,
and posts them anywhere.

The Host header is what still distinguishes the two: a rebound request
carries `evil.com`, because that is the name the page was fetched from.
Check it once at the router entry, ahead of the identity document, which
is otherwise a free fingerprint for exactly that page.

Loopback by address, by name and by IPv6 literal are served; the hosts of
any declared allowedOrigins are served, so an app that configures its own
UI origin is unaffected; allowedHosts covers a deliberate exception. A
lookalike like `localhost.evil.com` is not served.

Tested over a raw socket rather than in the conformance suite, because
fetch forbids setting Host and a rebinding attacker is a browser that
sets it implicitly.
npm strips files named `.gitignore` out of a published tarball. All four
blueprint templates ship one, `files` includes `toolkits`, and none of
them survive: `npm pack --dry-run` on framework/cli lists 189 files and
zero gitignore entries. So `npm i -g agent-app` followed by `scaffold`
produced an app directory with no ignore rules at all, and the next
`git add -A && git push` published the agent token, the principal
password and the live database.

The rules the framework itself depends on are now written by the CLI on
both scaffold paths, and the database rule follows the toolkit's declared
lifecycle.dataDir rather than assuming `data/` — a pocketbase app gets
`pb/pb_data/`. Existing lines are kept and only missing ones appended, so
a blueprint's own rules survive and re-running changes nothing.

Verified by scaffolding with the template's .gitignore deleted, which is
what an npm install looks like: the app still comes out protected.

blueprint-react-node's template was also the odd one out — it omitted
.superuser and every .a2app path, and narrowed the database to
data/db.json while backups are written to .a2app/backups/. Brought in
line with the other three.
`await request.body()` concatenates the whole stream with no limit, and
uvicorn imposes none either. It ran before the origin check, the
credential check and the rate gate — before routing at all, so the path
did not even have to exist: POST /api/junk with a multi-gigabyte body was
buffered in full and only then answered 404.

adapter-core caps at 5 MiB and says why: "a protocol write is tiny;
anything larger is a mistake or an attack, and buffering it whole would
let a hostile client OOM the app". The react-node blueprint inherits
that; this one had no equivalent.

Count bytes as they arrive rather than trusting Content-Length, and
answer 413 in the protocol's own envelope. A malformed body now becomes
{"__unparsed__": ...} for the guard to reject on its own terms, matching
adapter-core, instead of raising out of json.loads into a bare 500.
main moved a long way while this sat open, and three files conflicted.
Resolved so both sides keep what they added:

- adapters/adapter-core/package.json - main added test/static.test.mjs,
  this branch added test/host.test.mjs. Both now run. main's formatting is
  kept; the wholesale reindent on this side was an accident of the tool that
  wrote it, not a change anyone asked for.
- framework/cli/package.json - same shape: manifest-merge and containment
  both run, alongside copy-tree.
- toolkits/blueprint-react-node/template/server.mjs - main moved static
  serving into the View (view.handler). Took main's structure and re-applied
  the loopback binding on top of it, which is the fix that mattered here:
  listen(PORT) alone binds every interface.

All six security fixes verified present after the merge, and the scratch
files that were reverted on main do not come back - the revert wins over
this branch's older copy of them.

build and typecheck clean; conformance 60/60; every suite on both sides
passing.
@CraftOS-dev
CraftOS-dev merged commit d929615 into main Sep 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants