Project

Security

SeeKi touches production databases. The defaults assume that, and the code is written to keep a read-only product read-only.

Threat model, in one paragraph

SeeKi runs locally, binds to 127.0.0.1, and talks to databases you already have credentials for. The risks we take seriously are: a crafted URL or header that turns a SELECT into something more destructive; secrets leaking out of the config file; and a logic mistake that ever issues a write. Everything below is a control against one of those.

SQL injection — identifier whitelist

Table and column names cannot be bound as SQL parameters, so they are interpolated as strings. Every such name passes a whitelist before it reaches a query. The check lives in src/db/postgres.rs and refuses anything outside a small, double-quote-safe alphabet:

/// Validate that a name is safe for use as a double-quoted SQL identifier.
/// Allows alphanumeric, underscore, hyphen, and space — all safe inside `"..."`.
fn is_valid_identifier(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == ' ')
}

Every identifier is validated twice: once for its shape, once against the actual schema returned by introspection. A request is rejected if either check fails:

for col_name in filters.keys() {
    if !is_valid_identifier(col_name) {
        return Err(ValidationError(format!("Invalid filter column name: {col_name}")).into());
    }
    if !valid_column_names.contains(col_name.as_str()) {
        return Err(ValidationError(format!("Unknown filter column: {col_name}")).into());
    }
}

The same pair of checks runs for sort columns before they are interpolated into the ORDER BY clause. A name that is shaped right but does not exist in the schema is still refused.

Parameterised values

Filter values, search strings, pagination offsets, and every other user-supplied value travel through sqlx bind parameters. The code never builds a SQL string by concatenating a value; it builds the SQL with placeholders and hands the values to the driver. That keeps the parser on the database side from ever re-interpreting a value as code.

In practice this means every sqlx::query(...) call in db/postgres.rs pairs each $1, $2, … with a matching .bind(value). A review that spots a raw value inside a SQL literal is a review that requests changes.

No secrets in the repository

  • seeki.toml is gitignored. It is where your database URL and credentials live. The repository ships seeki.toml.example as a template; the real file is created by the setup wizard or by hand, and it never gets committed.
  • Credentials are not logged. The server logs table names and row counts, not connection strings. If you see a URL in a log, that is a bug — open an issue.
  • SSH keys are read from disk, not embedded. When tunnelling, SeeKi points at a key file you already have. Paths are in the config; the key material stays in your home directory.
  • Do not paste config into bug reports. Redact the URL before filing. The maintainers will ask for the schema name or error message, not the password.

Read-only by construction

  • Every SQL statement in db/postgres.rs is a SELECT or a catalog read (information_schema, pg_catalog). There is no INSERT, UPDATE, DELETE, TRUNCATE, or DDL anywhere in the binary.
  • The HTTP surface mirrors that: GET /api/tables, GET /api/columns, GET /api/rows. There is no mutating verb and no endpoint that accepts a body larger than a query string.
  • Operators who want belt-and-braces enforcement should connect SeeKi with a read-only database role. The app will not issue a write, but a role with only SELECT privileges turns the invariant into a hard constraint the database checks.

Network surface

  • The server binds to 127.0.0.1:3141 by default. It is not reachable from another machine unless you change the bind address yourself.
  • There is no built-in authentication. If you expose the port to other hosts, put a reverse proxy with auth in front of it. An auth module is on the roadmap, not in the current build.
  • Errors are returned as structured JSON with human-readable messages. Stack traces and query text stay on the server side.

Responsible disclosure

If you find a vulnerability, please do not open a public issue. Email the maintainer directly:

  • Contactsecurity@seeki.dev.
  • What to include — a description, a reproducer (smallest useful case), the affected version from VERSION, and the platform you hit it on.
  • What to expect — an acknowledgement within a few days, a fix plan, and credit in the changelog once the fix ships, unless you prefer to stay anonymous.

Low-severity and hardening suggestions can go in a regular GitHub issue.