Exposure
https://tndm.loca.zone answers from the public internet, and nothing in the application
authenticates a request. That is a property of the source, not an impression of it:
cd /home/loca/dev/tandem-audit
grep -rniE 'authorization|authenticat|bearer|jwt|cookie|password|credential|api-key|apikey|csrf|locals\.user' \
src/ --include='*.ts' --include='*.svelte' --include='*.html'exits 1 with zero matches. src/hooks.server.ts does not exist (ls src/hooks* reports no such
file), so SvelteKit’s one server-side request hook — the conventional place a gate would live — is
absent as well. Dropping the --include filters produces eight matches, all inside the frozen
parser fixture src/lib/mdframes/fixtures/qwizz-tour.md from line 617 onward, where they are
screenshot captions describing QuizWizz’s own password form. No route, module or component reads a
credential.
Three controls do exist. None of them is authentication:
| Control | What it actually protects |
|---|---|
HOST=127.0.0.1 in the unit, observed as 127.0.0.1:51818 in ss -ltn | the origin port, against direct off-host connections only. nginx is inside the host, so it is unaffected |
| TLS at nginx, one certbot certificate for both names | the transport. The upstream hop is plain HTTP over loopback (proxy_pass http://127.0.0.1:51818, /etc/nginx/sites-available/tndm.loca.zone:7 and :22) |
| The import realpath gate | the filesystem boundary of one endpoint, and only outside /home/loca/dev |
Reachability of the vhost is therefore the entire access control. See operations for the unit, the vhost and the port convention those observations come from.
What an unauthenticated caller can do today
Worst first. Every item below needs one curl and no credential.
Turn any file in the dev tree into a publicly readable deck
POST /api/decks/import with md_path accepts any path that resolves inside /home/loca/dev.
The boundary is two constants and one containment test:
const DEV_ROOT = '/home/loca/dev'; // importer.ts:25
function isInside(root: string, target: string): boolean { // importer.ts:52-54
return target === root || target.startsWith(root + path.sep);
}
async function readLocal(mdPath: string): Promise<Source> { // importer.ts:77-90
const resolved = await realpathOr400(mdPath, 'md_path not found');
if (!isInside(DEV_ROOT, resolved)) {
throw new HttpError(400, `md_path must be inside ${DEV_ROOT}/`);
}
if (!(await stat(resolved)).isFile()) {
throw new HttpError(400, 'md_path is not a regular file');
}
return {
md: await readFile(resolved, 'utf8'),
source: resolved,
name: path.basename(resolved)
};
}That is the whole check: symlinks resolved, containment, regular file. There is no extension test,
no allowlist and no read of the file’s content before it is accepted, so “markdown” means “any
regular file the loca user can read”.
Four live probes against https://tndm.loca.zone fix the boundary exactly. The two that fail on
containment and the two that pass it are the point:
md_path | Response | What it proves |
|---|---|---|
/etc/passwd | 400 md_path must be inside /home/loca/dev/ | outside the root is refused |
/home/loca/.bashrc | 400 md_path must be inside /home/loca/dev/ | the home directory outside dev is refused too |
/home/loca/dev | 400 md_path is not a regular file | containment already passed; only the file-type check stopped it |
/home/loca/dev/nope.md | 400 md_path not found | containment already passed; only existence stopped it |
The last two are the finding. A different error message means the request cleared isInside and
died on a later, unrelated check — so every existing regular file under /home/loca/dev is
importable. That tree holds 32 top-level entries, among them every project checkout on this host.
/home/loca/dev/wrdp/creds.md exists inside it (test -f confirms; its contents are deliberately
not reproduced here or read through the API).
Import is not a private operation. Whatever is imported becomes anonymously readable:
GET /api/deckspublishes the resolved source path of every deck. It currently returns"source":"/home/loca/dev/wrdp/q5vault/audits/qwizz/2026-08-20-feedback-tour.md".GET /api/decks/:idreturns the parsed document — every heading, caption, table and code fence.GET /api/decks/:id/assets/...returns the copied bytes..../assets/shots/g05-d-p06-question.pnganswers 200image/png, 33332 bytes.
With copy_from, referenced assets are copied out of the source tree into the deck as well, from
either /home/loca/dev or the data dir (resolveCopyRoot, src/lib/server/importer.ts:160-171).
A caller who can name a directory can therefore republish its images through the deck asset route.
Append events into any existing session
POST /api/sessions/:id/events takes an event and appends it (src/routes/api/sessions/[id]/events/+server.ts:17-26).
The body is validated, never authorized: actor must be the literal user or agent
(src/lib/server/store.ts:266-268), kind must be a known kind, text and friends must be strings
under 16384 characters (src/lib/server/store.ts:273-282). Anyone can write a note, a task, a
verdict or an agent say line into a live review, and it will be indistinguishable from the real
participant’s. Appending as actor: 'agent' is the documented attach mechanism
(see api and agent-cookbook), so there is no privileged writer to impersonate — the write
side is open by construction.
The log is append-only and seq-ordered, so injected events cannot rewrite history. They can only be added to it, and the export in first-deck would carry them.
Create sessions
POST /api/sessions {deckId} creates a session directory and an empty events.jsonl
(src/lib/server/store.ts:153-168). The only precondition is that the deck exists — readDeck runs
first and 400s or 404s otherwise (src/lib/server/store.ts:154). Unbounded in count.
Upload scribble PNGs
POST /api/sessions/:id/scribble decodes a base64 data URL and writes it under
sessions/<id>/scribbles/ (src/lib/server/store.ts:387-416). The payload must match
/^data:image\/png;base64,/i and must decode to at least one byte
(src/lib/server/store.ts:381, :396-400), and that is the only content check. There is no
application-level body size limit anywhere in src/lib/server/http.ts, so the write is bounded
solely by nginx client_max_body_size 64m (/etc/nginx/sites-available/tndm.loca.zone:4) and by
free disk.
Read every deck, session and event
GET /api/decks, GET /api/decks/:id, GET /api/sessions, GET /api/sessions/:id,
GET /api/sessions/:id/events, GET /api/sessions/:id/stream and
GET /api/sessions/:id/export.md all answer any caller. A review in progress is public reading,
including notes and transcribed speech.
What is already contained
The code is defensive about paths and payloads even though it is indifferent about callers.
| Guard | Where | Evidence |
|---|---|---|
Import refuses anything outside /home/loca/dev | src/lib/server/importer.ts:79-81 | live 400 md_path must be inside /home/loca/dev/ for /etc/passwd |
| Import refuses non-http(s) URLs | src/lib/server/importer.ts:99-100 | live 400 md_url must be http(s) for md_url: "file:///etc/passwd" |
| Remote fetch cannot hang the request | src/lib/server/importer.ts:29, :102 | 20 s AbortSignal.timeout on the import fetch |
| Deck and session ids are pattern-checked before becoming paths | src/lib/server/store.ts:59-64 | /^[a-z0-9][a-z0-9._-]*$/i, 128 characters max |
| Asset serving is containment-checked, not string-checked | src/lib/server/store.ts:51-57, :75-81 | live 400 resolved path escapes the data directory for /api/decks/qwizz-2026-08-20/assets/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd |
| Only regular files are served as assets | src/routes/api/decks/[id]/assets/[...path]/+server.ts:19-25 | directories and specials 404 |
kind and verdict are validated with Object.hasOwn | src/lib/server/store.ts:269, :291 | inherited prototype keys such as constructor cannot satisfy the lookup |
| Data URLs never reach the event log | src/lib/server/store.ts:415 | the scribble event carries file, the filename, and nothing else |
| The collab room secret stays out of listings | src/lib/server/store.ts:221-229 | GET /api/sessions rows are built field by field; live response keys are createdAt, deckId, doneSections, id, lastEventTs, lastSeq |
| Route errors do not leak internals | src/lib/server/http.ts:8-14 | HttpError keeps its message, anything else becomes an opaque 500 |
One nuance on the traversal probe, because the naive version of it looks like a pass for the wrong
reason. Sending the un-encoded form with curl --path-as-is
(/api/decks/qwizz-2026-08-20/assets/../../../../etc/passwd) returns the SvelteKit 404 page, and
the journal records [404] GET /etc/passwd: the dot segments were collapsed before the request ever
reached the route, so deckAssetPath was never consulted. Only the percent-encoded form survives
normalization, reaches the guard, and produces the 400 quoted above. A traversal test that does not
encode proves nothing about the guard.
The single-session read is the one deliberate exception to secret hygiene: readSession returns
collabLink when one is set (src/lib/server/store.ts:192-193) and GET /api/sessions/:id hands
back the whole session object (src/routes/api/sessions/[id]/+server.ts:9-11). Any caller who knows
a session id therefore gets that session’s collab link. The disclosure path is code-grounded rather
than demonstrated, because the live session 127d38c42c95512f has no link set: its session object
carries only id, deckId, createdAt, lastSeq, and GET /api/sessions/127d38c42c95512f/collab
answers 200 {"link":null}. The routes themselves are live and validating — the same session
answers 400 {"ok":false,"error":"link is not a recognizable collab link"} to a POST of
{"link":"not-a-link"}.
The open decision
No decision has been made. Three options are on the table; each fixes a different part of the problem and each costs something real.
| Option | Fixes | Costs |
|---|---|---|
| Authelia one_factor in front of the vhost | anonymous access to the UI and, if applied to /api/, to the API | an unauthenticated API call is answered with a 302 to the login page instead of JSON, which breaks agent curl and every non-browser client unless /api/ is excluded or separately token-authed |
nginx bearer token on /api/ | anonymous API access, including import | breaks the browser UI, which fetches /api/ from the page with no credential, and cannot be applied to the event stream at all because EventSource sends no custom headers |
Narrow the import root to /home/loca/dev/wrdp/q5vault | the file-disclosure surface, which is the worst item above | TNDM stops being a general markdown reviewer; anonymous read and write of decks, sessions and events is untouched |
Authelia one_factor
The working pattern on this host is cdsr.loca.zone: the vhost includes
snippets/authelia-location.conf at server level and snippets/authelia-authrequest.conf inside
location / (/etc/nginx/sites-enabled/cdsr.loca.zone:6 and :10). The first snippet defines an
internal subrequest to the Authelia authz endpoint on 127.0.0.1:51091; the second issues
auth_request /internal/authelia/authz, forwards the resolved identity as Remote-User and
friends, and ends with error_page 401 =302 $redirection_url;.
That last line is the whole problem for TNDM. A rejected request does not get a 401 an agent can
detect and handle — it gets a redirect to a login page, which a curl follows into HTML. The change
itself is small: the two include lines land in
/etc/nginx/sites-available/tndm.loca.zone, at server level and inside location / at line 21. The
decision is what happens to location /api/ at line 6. Leaving it ungated keeps agents and the
event stream working and leaves the entire API open, which is most of the exposure above. Gating it
too closes the API and breaks every documented agent recipe in agent-cookbook until the agents
carry an Authelia session cookie.
nginx bearer token on /api/
A credential check inside the location /api/ block at
/etc/nginx/sites-available/tndm.loca.zone:6-19, rejecting requests whose Authorization header
does not match a shared token, with Authelia or nothing on the UI at location /.
This is the option that reads well and fails on contact with the UI. The workspace is a SvelteKit client that calls the API from the browser with no credential, at fourteen call sites:
src/routes/+page.ts:9,:21andsrc/routes/+page.svelte:22— deck list, session list, session creation.src/routes/s/[sessionId]/+page.ts:7,:13,:24andsrc/routes/s/[sessionId]/+page.svelte:98,:216— session load, event backlog, event posting.src/lib/Scribble.svelte:184,src/lib/voice.ts:237,src/lib/components/DeckPicker.svelte:35,src/lib/components/CollabRail.svelte:50,:78,:104— scribble upload, speech events, deck switching, collab link management.
All of them would 401. The live stream is worse than inconvenient: it is opened as
new EventSource(/api/sessions/${id}/stream?after=${after})
(src/routes/s/[sessionId]/+page.svelte:285), and the EventSource API has no way to set a request
header, so no bearer scheme can be satisfied from the browser at all. Making this work means either
embedding the token in every client call and accepting that it is public, or moving the stream to a
cookie-authenticated path — a code change, not a config change.
Narrow the import root
One constant: DEV_ROOT at src/lib/server/importer.ts:25, with the matching copy_from message
at :30-31 and its containment test at :168.
Pointing it at /home/loca/dev/wrdp/q5vault keeps the current deck working. Its deck.source is
/home/loca/dev/wrdp/q5vault/audits/qwizz/2026-08-20-feedback-tour.md, and every image that
document references is under /assets/qwizz-tour/2026-08-20/ — 315 under shots and 5 under
boards, both of which exist as real directories inside
/home/loca/dev/wrdp/q5vault/assets/qwizz-tour/2026-08-20. Document and assets are therefore both
inside the narrower root. What it gives up is the property the rest of this wiki is built on: that
any markdown document is reviewable. Importing this wiki’s own pages from
/home/loca/dev/wikis/tndm/content, a plan, or another project’s notes would all start returning
400. It also fixes nothing about anonymous reads and writes of what is already imported.
Trust boundaries that are intentional
Two things are open on purpose, and should not be filed as defects.
The collab room secret is the capability. TNDM stores an OMP collab link per session and hands it to
the browser rail; possession of the link is the entire trust model, and OMP’s own reference is
explicit about the two strengths — a full link is 48 bytes, a 32-byte AES-256-GCM room key plus a
16-byte write token, granting prompting, interrupting and subagent control; a view-only link is the
bare 32-byte key, granting live read access only. Both facts come from omp read omp://collab.md:
the byte counts are in its “Link format” section, and its “End-to-end encryption” section states
outright that “Possession of the link is the trust boundary”. The collab route enforces exactly
those two shapes, 64 and 43 base64url characters
(src/routes/api/sessions/[id]/collab/+server.ts:23-24, with the reasoning at :17-21), because
anything else is a link the guest client refuses to connect with. TNDM keeps the link out of logs,
events and session listings, and that is the correct handling of a secret it did not mint. It cannot
make the link less powerful than OMP made it.
Bench evidence in a deck is bench-local. The first deck is the QuizWizz UI tour captured on the WRDP
bench, and everything derived from it — verdicts, notes, recaptures, the exported feedback log —
inherits releaseEligible: false, which the live deck.json carries verbatim in deck.meta,
propagated from the source document’s frontmatter. It documents what the bench showed on a date. It
is not a release artifact, and nothing on this wiki should be read as shipped. first-deck carries
that deck’s provenance and evidence carries the verification receipts.
Read next
- operations — the unit, the vhost, the runbook, backup gaps and the proposed unit hardening.
- api — every endpoint and its exact contract.
- agent-cookbook — the attach recipes that any credential scheme would have to keep working.
- index — what TNDM is.