Laurelin is designed to hold data people care about — that's the whole premise of the governance layer — so this document states plainly what we protect, what we don't, and how to report a problem.
Please do not open a public issue for a security bug.
Use GitHub's private vulnerability reporting on this repository (Security → Report a vulnerability), which opens a private advisory visible only to maintainers.
Please include: what you found, how to reproduce it, the version or commit, and what an attacker gains. A proof of concept helps enormously.
What to expect
| Acknowledgement | within 3 business days |
| Initial assessment (severity + plan) | within 10 business days |
| Fix for high/critical | prioritized over feature work |
| Credit | in the advisory and release notes, unless you'd rather not |
Laurelin is a young, volunteer-maintained project: we will be honest with you about timelines rather than promise a 24-hour SLA we can't keep. If a report is serious and we're slow, escalate by opening a public issue that says only "privately reported security issue awaiting response" with no details.
We consider good-faith security research a contribution, not an attack. We won't pursue action against researchers who follow this policy, act in good faith, and avoid privacy violations, data destruction, or service disruption.
Laurelin is pre-1.0 (0.x). Only the latest release receives security
fixes. There are no backports to older 0.x releases. When 1.0 ships, this
section will state a real support window.
Understanding the trust boundaries matters more than any individual control.
Authentication. Local accounts (scrypt password hashing, per-user salt,
constant-time-ish verification that runs scrypt even for unknown usernames so
username enumeration isn't a timing oracle), OIDC, SAML 2.0, SCIM 2.0
provisioning, and API tokens. Sessions are opaque random tokens in httpOnly,
SameSite=Lax cookies — with the Secure flag when --secure-cookies is set.
Session and API tokens are stored only as SHA-256 hashes; a database dump does
not yield usable credentials. The disabled flag is checked on every
credential resolution, so disabling a user (including via SCIM deprovisioning)
takes effect on their next request — no waiting for a session to expire.
Authorization, composed in this order, evaluated on every read path:
role (viewer/editor/admin)
└─ dataset / ontology ACL grants
└─ mandatory classification markings (clearance for every effective marking)
└─ row-level security + column masking
The composition is the point: object-type access requires the ontology grant and backing-dataset access, so there is no path through the object API to data you cannot read directly. Row and column policy is applied at a single choke point shared by the rows API, the SQL page, dashboard panels, ontology materialization, and the MCP tools.
Markings propagate through lineage. A derived dataset inherits its inputs' classifications on every build, so a pipeline cannot launder classified data into an unmarked output.
The SQL surface is sandboxed. Ad-hoc SQL and dashboard queries run with
DuckDB external access disabled (catalog.py, SET enable_external_access=false),
over only the datasets the caller may view. read_csv('/etc/passwd'),
COPY … TO, path traversal, and attempts to widen the sandbox with SET are
all blocked, and there are regression tests for each
(tests/test_query.py, tests/test_federation.py). A dataset you can't see is
an unknown table, not a permission error — no existence oracle.
That is the sandbox around caller-written SQL, and it is not the same as a
filesystem sandbox around the whole process. Registering a federated or
ClickHouse source whose path is local is a "read any file the server process
can read" capability, because federation.connect disables
LocalFileSystem only for sources that are not local, and chdb has no
filesystem restriction to disable at all. It is therefore admin-only, and
exposing such datasets to ad-hoc SQL is additionally off by default behind
LAURELIN_FEDERATION_WORKBENCH=1. This is a property of the registration
route, not a hole in the SQL surface: callers receive an Arrow table, never a
connection, so only server-generated SQL reaches those engines.
The object_store ingestion source is secret-bearing, and its endpoint is
admin-authored. Registering one (PUT /sources, admin-only) stores an
access_key_id/secret_access_key pair for an S3/GCS bucket, redacted in
every API response and export by the same allowlist as postgres/http sources
(the key pair masks to *****; uri/endpoint_url disclose to admins only,
through redact_dsn; export withholds them whole so a re-imported source
refuses its first sync with a re-supply message rather than failing inside
DuckDB). The sync itself runs on a fresh, per-sync DuckDB connection that is the
only network-enabled connection in the process: it loads a temporary,
bucket-SCOPEd secret, then disables both LocalFileSystem and
HTTPFileSystem and locks the configuration. s3:// reads use DuckDB's
S3FileSystem and are unaffected; disabling HTTPFileSystem stops that
connection reaching any other http(s) host (incl. cloud metadata at
169.254.169.254) as defense-in-depth behind validate_source, which already
forces the uri scheme to s3:///gs://. Build and query connections keep
enable_external_access=false, which blocks s3:// even with a valid secret
loaded, and DuckDB secrets are per-instance and temporary, so no other path in
the process can use the sync's credential. The ingest is size-capped at
LAURELIN_MAX_UPLOAD_MB (the http puller's ceiling) so a triggered sync of a
huge object cannot mint an unbounded dataset.
There is an SSRF surface, and it is bounded by who may author the endpoint,
not by who may trigger the sync. The sync route's only gate is
_require_dataset_edit, so a plain editor, a viewer holding a can_edit
dataset grant, and the unattended scheduler can all cause a fetch to the
admin-configured endpoint_url — but none of them can supply it. This is the
same trust already extended to postgres/http sources: an admin can point the
server's sync path at an arbitrary URL. The residual, measured: a bucket-SCOPEd
secret is credential selection, not egress control, so an out-of-scope
s3:// URL still resolves against public AWS — bounded because the uri is the
same admin's config and nothing an editor supplies ever reaches the connection.
Object-store ingestion is verified against MinIO; real AWS S3, GCS HMAC
interop, and Azure are not yet tested (Azure registration is rejected pending a
test).
Secrets aren't echoed — and that no longer rests on recognising one. Three
rounds of attackers found credential disclosures in the module that tried to
detect credentials in free text, and each round something walked around the
newest regex. Finding a credential inside a string is not decidable: libpq
conninfo, ODBC keyword strings, JDBC URLs, CREATE SECRET bodies, a driver's
prose, and formats nobody has enumerated are all valid places for a password.
Two rules replace the detector, and nothing's confidentiality depends on it
any more.
R1 — third-party driver text is never persisted or returned. Every place a
driver or library exception is caught converts it, at the catch site, into a
Failure (laurelin/core/failure.py): a code and a phase from closed enums, a
subject in Laurelin's own namespace, a host:port rebuilt from Laurelin's own
parse of its own config, integer counters, and a detail_ref. The only
driver-derived fields are the exception's class name and its vendor code, both
gated on the shape of a Python identifier — which no conninfo, ODBC string,
JDBC URL, CREATE SECRET body or PEM block can satisfy. That question is
decidable; "does this contain a credential" is not.
There is a net under the catch sites, because there has to be:
@app.exception_handler(ValueError) and (KeyError) catch anything uncaught,
and pyarrow.lib.ArrowInvalid is a ValueError while ArrowKeyError is
a KeyError. An editor read an operator's S3 warehouse credential out of a 400
that way. failure.is_first_party decides on the deepest traceback frame —
where the raise is written — rather than on the exception's type, and anything
else becomes a Failure. The route-level except ValueError blocks above that
net apply the same rule through failure.safe_detail: most of them are catching
Laurelin's own validation and echoing the caller's own input back, which is
fine, but several wrap a call that reaches a third-party library, and the check
costs nothing when the exception is ours.
R2 — author-written free text is readable only at the privilege level that
could author it. If you cannot write it, you cannot read it. Fields declare an
audience (laurelin/core/audience.py); anything not explicitly PRESENTATION
is withheld from readers below the record's authoring role, enforced at one
serialization point (laurelin/core/serialize.py). A field added tomorrow with
no annotation fails closed; a model added tomorrow is admin-only. A field whose
writer sits above its record says so with AuthoredBy — DatasetInfo.source
is written only by the three admin registration routes, so it reaches admin and
nobody else even though a dataset is editor-authored. Descending into a nested
record can only ever disclose less.
The three practical consequences:
- A connector's
config, a federated dataset'ssourceand an engine's URI are not disclosed below admin at all. Not a better denylist over somebody else's config vocabulary — the absence of one. What a lower-privileged reader gets is a Laurelin-built descriptor (which table, in which format) assembled from an allowlist of shape keys with an identifier-shaped gate on every value. - A dashboard panel's SQL is not disclosed to a viewer. They get the rows:
POST /dashboards/{name}/panels/{id}/runexecutes the stored panel server-side as the caller, with that caller's ACL, row-level security and column masking. A stored dashboard still grants nobody new read access. - Error paths are read paths. A 400 that says a stored instruction is broken
must not quote the instruction to a principal who may not read it. The
message naming the offending field goes to whoever could have authored it;
everyone else gets a code and a
detail_ref.HTTPException(detail=…)does not pass through the serializer, so the routes that carry a failure callserialize.detail_forexplicitly.
laurelin/core/authoring_hints.py still holds the old free-text matcher. It is
an authoring hint and not a boundary: saving a panel that looks like it
contains a connection string succeeds with a warning in the response body rather
than a 400. Being wrong costs an editor a banner instead of a viewer a password.
tests/test_redaction.py::test_the_authoring_hint_is_not_load_bearing
monkeypatches the matcher to always return "clean" and re-runs the entire leak
battery; it passes.
What is still disclosed, deliberately. An author's captions reach the
audience they were written for: a dashboard panel's title, and the alias on
a metric, which becomes the column header a viewer reads. An editor who puts a
credential in a column header has disclosed it to their own readers on purpose,
exactly as they would by typing it into the panel title. The invariant Laurelin
asserts and tests is about instructions — a panel's sql, group_by,
filters, search, and its metrics' op and property — not about labels.
The server log is where driver text now lives, and that is a deployment
decision. R1 relocates a driver's exact words from a browser-readable
database column to the process log, tagged with the same detail_ref the API
returns. Who can read that is not bounded by any Laurelin role — it is
whoever can read your log sink. If those logs are shipped to a SIEM whose
readership is wider than your admin group, you have widened the audience of
every credential your operators have pasted into a connector config. Treat the
server log as credential-bearing and give it the same protection as
metadata.db. A best-effort substitution of known secrets runs on the log path
as a courtesy for a sink Laurelin does not own; it is explicitly not a boundary.
One data-destroying migration, on purpose. builds.error,
build_tasks.error, sources.last_sync_error and schedules.last_error are
set to NULL on upgrade. Those columns hold prose of unknown provenance that can
never be re-classified, they are known to contain live credentials, and they
sit in a file whose permissions were themselves a shipped bug. Every
pre-existing audit_log row is stamped min_read_role='admin' for the same
reason: its writer had no idea who would read it.
Mutations are audited with actor, action, and details.
Governance changes can require a second admin. The consequential governance
writes — dataset/ontology grants, row policy, masks, markings and marking
deletion, clearances, group membership, role changes, workspace membership —
pass through a single store-level chokepoint that demands a ChangeTicket, so
REST, MCP (which calls the same routes), SCIM and flow governance all classify
under the same gate; a route decorator would miss the service callers. A
comparator decides loosening vs tightening by evaluating each non-admin
user's capability before and after the change — exact, because the policy
language is closed and declarative — and it classifies against an account's
policy capability even when the account is disabled, because the disabled
flag is reversible by an ungated identity write (a real bypass that is now
tested). Tightenings apply immediately; loosenings file a proposal. In
second-approver mode (opt-in, and enabling it requires ≥ 2 active admins) a
loosening queues and the approver must differ from the proposer. Enforcement is
uniform across paths: a workspace import carrying governance rules is
refused while second-approver mode is armed rather than write them below the
gate through its raw-SQL importer.
Health and alert delivery leak nothing a viewer could not already read. The
GET /health/datasets rollup and GET /health/events feed are filtered per
dataset (a viewer sees only datasets they can read), there is no unfiltered
totals endpoint, and the event feed's ordinal is renumbered per response so a
global counter cannot leak the existence, count or timing of transitions on
datasets the caller cannot see. An outbound webhook payload is mechanically the
viewer-role projection of the health record, so it cannot carry a masked
value, a row count, a cursor_value or editor prose; the link is a relative
path, never an absolute URL.
The withholding boundary: what a name discloses, and where it stops. A
route may name a dataset — or a transform whose name is a dataset name — only
to a principal who may view that dataset. Where a name is withheld the response
drops the whole node and both its edges, and marks the surviving endpoint
with a single unquantified boolean (has_hidden_upstream,
has_hidden_downstream, hidden_inputs, hidden_tasks, hidden_targets,
hidden_transforms, hidden_downstream, others_exist). Never a count, never
a placeholder node: a placeholder preserves topology and cardinality, which is
enough to fingerprint a pipeline and to notice when a new hidden dataset
appears. No status is falsified to make a projection self-consistent — a
build whose withheld task failed still reads failed, and a failed_tasks
counter that recounts to zero is dropped rather than emitted.
A withheld resource and an absent one answer identically: 404, in the same
words, on GET /datasets/{n}, GET /builds/{id}, the Iceberg dataset routes,
GET /pipelines/{n}, GET /schedules/{n} and the ontology object-type routes;
400, in the same words, for a build target. The UI's not-found screen carries no
status code for the same reason.
Where the boundary deliberately stops: a name the principal supplied
themselves. POST /flows/preview — authoring, with a body the caller wrote —
tells its author whether a source they typed is absent or withheld, and the two
sentences differ. That is intentional: the flow source picker offers only
readable datasets, so this path is reached only by typing a name; the
discriminator is load-bearing for the stored-flow run path, whose viewer-facing
refusal is identical for both cases; and collapsing the two would make the
authoring surface lie to the author about why their flow will not compile. The
boundary is over names a principal did not supply. A principal who types a
name learns whether it is theirs — and nothing about any name they did not type.
Be clear-eyed about these. They are design consequences, not oversights:
-
Pipelines are trusted code. A transform is Python that the server imports and executes. Anyone who can write a pipeline file has remote code execution as the server process. In-browser authoring is editor-gated for this reason. On any deployment where editors are not fully trusted, serve with
--lock-pipelinesand manage pipeline files through git and code review. This is the single most important line in this document.Editors are trusted with code execution only where Python authoring is enabled. With
--lock-pipelines, editors can still author flows and Analyses charts — no-code artifacts that compile to bound, schema-checked SQL, are governance-checked against their recorded author at every build, and cannot reachexec— while pipeline files are managed on disk. Ejecting a flow to Python writes a.py, so it is locked with Python. Operators who want the old total lockdown (no authoring of any kind) add--lock-flows/LAURELIN_LOCK_FLOWS=1. Dashboard raw-SQL panels are a separate, always-available surface: they are persisted authoring but execute as the calling viewer under their own row policies and masks, and were never covered by either lock.Where Python authoring is enabled, an API-authored transform can no longer launder data: the saving user is recorded server-side, and every build refuses a python/sql transform whose recorded author cannot read every input in full — view rights, and no row policy or column mask that applies to that author — checked against the recorded author, never whoever triggered the build. "Applies to" is resolved by the same engine a read uses, so an admin (who bypasses policy) and an author exempt from a mask build, while an author the policy would filter is refused; that is what keeps a legitimate re-save from becoming a false refusal. The surviving trust assumption, plainly: pipeline files written on disk — git, import, the CLI — have no recorded author and build unchecked, as does a
--no-authserver (every request is the implicit admin, not a real user, so nothing accountable is recorded). Whoever can write topipelines/on disk already has code execution as the server, so the entitlement check binds exactly the population the API can identify and no one else. It does not touch the code-execution surface itself — a pipeline function body runs as the server and can read any dataset on disk regardless of its declared inputs; that is what--lock-pipelines(item 1 above) closes. -
Admins bypass markings and row policy. Deliberate: a mandatory-access system that can lock every human out of their own workspace is an availability incident waiting to happen. If you need admins constrained, separate duties across workspaces — don't assume the marking stops them.
-
--no-authdisables everything. Every request becomes an administrator. It exists for local development and the demo. Never expose a--no-authserver to a network you don't control. -
Dataset files are not protected from the OS. Anyone with read access to the workspace directory reads the Parquet under
data/directly, with no row policy and no column masks — access control is enforced at the API, not the filesystem. Protect the volume accordingly. The credential-bearing files are a separate matter and are handled:metadata.db(with its-wal/-shmsiblings),control.db,iceberg-catalog.dbandlaurelin.ymlare created0600, and a workspace root Laurelin creates is0700, as are thedata/,pipelines/,ontology/andiceberg/directories inside it. See the deployment checklist below for what happens to a workspace created before that.LAURELIN_DATA_URIpointing at a local path creates that directory0700too; pointing it at a scheme this build cannot address (s3a://, a mistypeds3:/) is a startup error, because the alternative was a local directory of that literal name holding every governed Parquet while the operator believed the data was in a bucket.pipelines/matters more than the other two: its contents areexec'd on every build, so a directory anyone can write to is code execution. Laurelin creates it0700; a directory that already exists keeps the mode it has, and Admin → Workspace files on disk shows the workspace root's actual mode. -
No encryption at rest. Use encrypted volumes or an encrypted Postgres.
-
Multi-tenancy is soft. Workspaces isolate data and membership, but they share a process. Treat the tenant boundary as an organizational one, not a hostile-tenant sandbox.
-
Change approval governs the network surface, not the filesystem. The approval gate binds REST, MCP, SCIM, the CLI and flow governance because they all pass through the ticketed store methods — but a local process that opens
metadata.dbdirectly (the CLI does; the file is0600) writes with alocalticket and no queue. Approvals are the honest-operator record and the network control, not a defense against a hostile operator with filesystem access. Two composition points are exempt by explicit decision and file an after-the-fact record rather than queueing: an IdP SCIM group push (configuringLAURELIN_SCIM_TOKENis the standing authorization — queueing would break push semantics), and--no-authmode (every request is the implicit admin). Both are stated inlaurelin/core/approvals.py. -
An alert webhook is an outbound surface an admin controls. The URL is admin-configured and off by default; there is no egress allowlist, so an admin can point a webhook at an internal address (a metadata endpoint, an internal service) and the server will POST there. This stays within the admin trust boundary — there is no non-admin path to configure a webhook or induce a delivery to an attacker-chosen URL — but if your admins are not trusted with outbound requests from the server's network position, do not enable webhooks. The URL is stored as a write-only credential (read back as
WITHHELD, omitted from export archives), so it is not disclosed to a later admin or an exported workspace; its destination is still the configuring admin's choice.
A short checklist; details in docs/DEPLOYMENT.md.
-
Never run
--no-authoutside localhost. -
Terminate TLS at the ingress; run with
--secure-cookies; ensureX-Forwarded-Protoreaches the app. -
Set
--lock-pipelinesunless every editor is trusted with code execution. Editors keep Flows and Analyses charts — no-code authoring that cannot reachexec— which is the intended production posture; add--lock-flowsonly if you want no authoring surface at all. -
Use PostgreSQL for the control plane in any multi-replica deployment, and back it up together with the data volume.
-
Give the server process its own OS user. Laurelin creates
metadata.db(with its-wal/-shmsiblings),control.dbandlaurelin.ymlat0600, and a workspace or server root it creates at0700— but a workspace from before that, or a directory you made yourself, keeps the mode it has. Opening an inherited database strips world access and group write, and leaves group read alone — so an upgraded workspace ends up0640, not0600, and that is the default outcome of every upgrade, not an edge case. Group write is not treated as a share:metadata.dbis the file that says who is an admin, and atumask 002a legacy0664database let any member of the group make themselves one withsqlite3. Admin → Workspace files on disk shows the mode each file actually has, re-stat'd on every request, includingcontrol.dbandiceberg-catalog.db, and distinguishes group write from group read.LAURELIN_STRICT_FILE_MODE=1forces0600on every open, and also takeslaurelin.ymland the workspace directory itself. Data underdata/is not re-moded at all. -
Set
LAURELIN_MAX_UPLOAD_MBto something sane for your box (it also caps HTTP-connector downloads). -
Rotate
LAURELIN_SCIM_TOKENand SSO client secrets like any other credential; keep them in a Secret, not invalues.yaml. -
Treat a workspace archive as untrusted input.
laurelin importis not signature-checked (see docs/PORTABILITY.md): the trailer proves the archive is internally consistent, not that it came from anyone in particular. An archive whose data parts collide with the destination's is now relocated to fresh keys rather than written over them, and the relocation is reported in the import warnings — but the rows it brings are still whatever the archive said. -
Review the audit log periodically — it only helps if someone reads it.
-
Treat the server log as an operator-privilege artifact, and check who your log sink is shared with. Laurelin no longer persists third-party driver text anywhere a browser can reach it: an exception from psycopg, mysql-connector, DuckDB or a Flight SQL driver is converted at the catch site into a structured
Failure(an error code, a phase, a subject in Laurelin's own namespace, our ownhost:port, integer counters) and the driver's own sentence goes to the process log, tagged with the samedetail_refthe stored failure carries.That is a *relocation*, not a sanitization, and it is the deliberate trade: an operator has to be able to read what actually broke. A driver quotes back the connection string it was handed, so those log lines can contain any credential an operator has pasted into a connector config. **If your logs are shipped to a SIEM whose readership is wider than your admin group, you have widened the audience of every one of those credentials.** Laurelin makes a best-effort pass to substitute out the secrets it can name from the config in hand; that is a courtesy for a sink Laurelin does not own, not a control — see `laurelin/core/authoring_hints.py`.
In scope: authentication and session handling, the authorization layers above, the SQL sandbox, secret exposure, injection, path traversal, CSRF, privilege escalation between roles/workspaces, and marking/lineage bypass.
Out of scope: anything requiring --no-auth; attacks by a user who
already has pipeline-write access (see boundary 1); social engineering; and
vulnerabilities in dependencies without a demonstrated exploit path through
Laurelin.
Resource exhaustion is partly in scope, and here is exactly how far it
goes. Interactive queries are budgeted — laurelin/core/limits.py applies
a memory limit, a wall-clock timeout enforced by a watchdog that calls
DuckDB's interrupt(), and a per-replica concurrency semaphore that refuses
past its bound with Retry-After rather than queueing. All three are tunable
(LAURELIN_QUERY_MEMORY_LIMIT, LAURELIN_QUERY_TIMEOUT,
LAURELIN_MAX_CONCURRENT_QUERIES) and are translated into ClickHouse's and
StarRocks' own settings for those engines. So one expensive query fails its
own request rather than the replica. What is not bounded: builds get a
looser budget and no timeout by default (LAURELIN_BUILD_TIMEOUT=0), and
there is no general request rate limiter — the only throttle in the tree is
on failed logins (5 consecutive failures per username → 30s lockout, 429), so a
caller who issues many cheap authenticated requests is unbounded. Report a
way to take a replica down through the query budget; a report that says
"unbounded builds exist" is describing a documented default. Ingestion pulls
(http and object_store sources) cap the pulled size at
LAURELIN_MAX_UPLOAD_MB (default 1024 MB) so a single sync cannot mint an
arbitrarily large managed dataset, but the number of syncs is not rate-limited
beyond the edit gate above.