Please do not open a public issue.
Use GitHub's private vulnerability reporting on this repository, or email touheedkhan1408@gmail.com.
Tell us what you found and how to reproduce it. You will get a reply within a few days. This is a small project run by one person, so please be patient with the timeline, but the report will be taken seriously.
There is no tagged release yet. Fixes land on the tip of main and in the
touheed10/code_scout:edge Docker image, and upgrading is how a fix reaches you. GET /healthz
reports the version and commit an instance is running without a login, so a report can say exactly
what it was tested against.
Code Scout runs on your own server, so there is no hosted service of ours to attack and no shared tenancy to escape. What is left are the places where something outside the instance is trusted, and those are the ones worth your time:
-
The ingest endpoint.
POST /api/logs/dumpis authenticated with the project ID and secret the SDK sends on every upload. Anything that lets one project write into another, or lets an unauthenticated caller write at all, is in scope. -
Who can see which project. Every account has one instance role, and separately a level on each project it has been added to. A super admin sees every project without being a member of any, and that is the only role that skips membership; anyone else with no membership row has no access at all, including an admin who was never added. Deleting a project needs both maintainer on that project and the instance-wide Admin role. All of this is decided in one function,
ResolveProjectAccessininternal/domain/access.go, and enforced by middleware on the/project/{id}subrouter, so a handler cannot forget the check. On the dashboard, a project you cannot see answers 404 and never 403, so you cannot learn that it exists. Anything that reaches another team's logs is in scope. -
The live session socket. A device gets onto
GET /api/live/socketwith the same project ID and secret it uses to upload, checked by middleware before any pairing frame is read, so a pairing code never decides whether a device may connect, only which session it joins. A code is six characters fromcrypto/rand, single use, dead after five minutes, and bound to the project that minted it. A code that pairs a device into another project's session, or one that survives being claimed, is worth reporting. -
The live database browser. While a device is paired, the dashboard can browse the app's own local storage and change one cell at a time, which makes it the most invasive feature in the product. Two properties hold it together. First, the dashboard never sends SQL: there are five structured operations and none of them has a field a statement could travel in, so read-only is a property of what can be expressed rather than a rule anyone enforces. Second, writing takes two gates: the dashboard side needs project-manage rights, the same bar as rotating a secret, and the app must have registered that database with
writable: true. Nothing is browsable at all until the app callsregisterDatabase. A path that smuggles a statement to the device, reads a database the app never registered, or writes past either gate is very much in scope. -
Personal access tokens and the MCP endpoint.
/api/mcplets a coding agent read a project over MCP, authenticated withAuthorization: Bearer csp_..., a personal token minted on/account. Only a hex SHA-256 hash of the token is ever stored, inpersonal_access_tokens; the plaintext is shown once at minting. Tokens can be revoked on the same page, can carry an expiry, and are capped at 20 per account. A token grants exactly what its owner can already see: every tool resolves access through the same resolver the web router uses, and a malformed project id, an absent one and one the owner cannot read all answer "project not found", byte-identical. The eighteen tools are read-only by construction: each database-browser tool hardcodes its operation, no input carries an op, a statement or a value, and the rows limit is a server constant. A tool that can be made to write, or a token that reads a project its owner cannot, is in scope. -
The dashboard session cookie and the login flow.
cs_sessionis a random token checked against theuser_sessionstable on every request, and it lasts thirty days. Anything that gets you a session without knowing a password, or keeps one working after a password change or a reset, is worth reporting.
Project secrets are stored in plain text. The reason is that the SDK setup screen has to show
you the secret again, and re-reading it there is how most people set up a second app. The secret
is 32 characters drawn from a 62-character alphabet with crypto/rand, not something a person
chose, so a stored hash would buy less than it sounds like it would. The comparison on the ingest
path is a plain string equality rather than a constant-time one, which we are comfortable with for
a long random key arriving over a network and would not be for anything shorter. Treat a project
secret like an API key: rotate it if it leaks, which the settings screen supports. Rotation
replaces the secret in one transaction, so every installed build of your app starts failing
uploads immediately and keeps its logs locally until a build with the new secret ships. Both a
reveal and a rotate are logged at warn with the project id, so you can see afterwards that one
happened.
The ingest path tells you which header was wrong. An SDK sending a project ID that does not exist gets a different error from one sending a wrong secret, both as HTTP 400, because a developer wiring up a new app needs to know which of the two they got wrong. That means the ingest endpoint is the one place the server will confirm to a caller with no credentials that a project exists, so the dashboard's 404-never-403 rule deliberately does not extend here. Project IDs are UUIDv7, which is worth being precise about: the first 48 bits are the creation time in milliseconds, so an id is not uniformly random and knowing roughly when a project was made narrows the space. What is left is 74 random bits, which is far past guessable, but if you find a way to turn this endpoint into a practical oracle we want to hear about it.
Nothing in a log is redacted unless your app names it. A debugging tool that hides the
Authorization header is not much of a debugging tool, because that header is often the reason
the request is failing. So Code Scout hides nothing by default. Whatever you name in
RedactionBehavior is stripped on the device at capture, before anything reaches the phone's own
database and long before an upload, so a redacted value never existed anywhere the server could
see it. The flip side is that redaction is not retroactive: it only affects logs written after you
turn it on, and whatever already reached the dashboard stays there until retention removes it or
you delete the project. Decide this before pointing the SDK at production, not after. See
the redaction guide.
The daily cap can overshoot by up to one batch per concurrent upload. Enforcement is check then act with no row lock, on purpose, because locking would serialise every upload for a project onto one row on the hot path. It is a volume control, not a billing boundary.
reset-password needs nothing beyond a shell. code_scout reset-password --email=... prints
a fresh temporary password once and signs that account out everywhere. Anyone who can run it can
take over any account, which is the point: the trust boundary is shell access to the machine the
server runs on. A finding that begins "given shell on the host" is not a finding.
The MCP transport disables the SDK's localhost protection on purpose. The Go MCP SDK refuses a
loopback connection carrying a public Host header, a DNS-rebinding guard for local servers that
have no authentication. This endpoint always has authentication, because the bearer-token
middleware sits in front of it, and a rebinding attack cannot carry the Authorization header. A
reverse proxy forwarding to 127.0.0.1 with the public Host is exactly the shape the guard refuses,
so DisableLocalhostProtection: true is load-bearing rather than a hardening flag someone forgot,
and TestAProxiedHostHeaderIsNotRefused fails if it comes back.
- Register your own account before the instance is reachable from anywhere but your own machine. Until the first account exists, the login page is a signup page, and whoever fills it in becomes the super admin of every project on the instance.
- Put it behind TLS, and redirect plain HTTP rather than serving it. The session cookie is set
HttpOnlyandSameSite=Laxbut notSecure, and there is no setting that adds the flag, so a single request overhttp://puts a thirty-day session token in the clear. Sending HSTS from the proxy closes that for good. The project secret and any personal access token travel on every request too. - Change both passwords in
docker-compose.ymlbefore anyone else can reach the instance.CS_DB_PASSWORDandPOSTGRES_PASSWORDship aschange-me, and they have to match. - Set
CS_DB_SSLMODE=requireor stronger if the database is not on the same host. - Put whatever rate limiting your proxy offers in front of
POST /api/auth/submit. The server does not throttle login attempts, and it accepts a password as short as six characters, so pick a longer one. - Treat a personal access token like a password for everything its owner can see. It tends to live
in editor and agent configs for months, so mint it with an expiry when you can, and revoke it
from
/accountthe moment nothing uses it any more.