Security: datj9/enclave

Security

SECURITY.md

Security

Reporting a vulnerability

Report privately, through GitHub's private vulnerability reporting:

https://github.com/datj9/enclave/security/advisories/new

Do not open a public issue, and do not include a working exploit in anything public. If private reporting is unavailable on the repository, contact the maintainer through the address on their GitHub profile (https://github.com/datj9) and say only that you have a security report — details after a private channel is established.

Please include: what you did, what happened, what you expected, and the version or commit you tested. A proof of concept helps; a paragraph of prose about what is theoretically possible usually does not.

What to expect: this is a single-maintainer project with no support contract. You should get an acknowledgement within a week. There is no bug bounty and no reward beyond credit in the advisory, which you can decline. Once a fix is out, the advisory is published — please hold public disclosure until then.

Supported versions

v1 only. There are no earlier releases, and no backports.

The artifact isolation model

This is the part of enclave worth attacking, so here is exactly how it works and where its limits are. Artifacts are HTML, CSS and JavaScript written by a language model from a user's prompt. They are untrusted code that runs in your users' browsers, and the entire design follows from that.

One browser origin per artifact

Every artifact is served from its own hostname:

{artifactId}.artifacts.example.com

The artifactId is a UUID, and a host that merely looks like an artifact origin without a well-formed UUID in that position is not treated as one. The app itself lives on a different hostname, and nothing on the app origin is reachable from an artifact origin.

This is not cosmetic. Because each artifact has its own origin, the browser gives every artifact its own localStorage, IndexedDB, cookie jar and same-origin scope. Artifact A cannot read artifact B's stored data, because to the browser they are unrelated sites.

The sandbox, and why allow-same-origin is here

Artifacts render in an iframe on the app's viewer page:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"

allow-same-origin looks alarming in a sandbox attribute, and usually is. Without it, the browser puts the frame in an opaque origin, where localStorage and IndexedDB throw on access — a large share of generated artifacts store something, so they would simply break.

It is safe here for one specific reason: the origin the frame is allowed to be same-origin with is a hostname belonging to exactly one artifact, and that origin holds nothing worth stealing. The only cookie on it is a grant cookie scoped to that single artifact, whose payload is checked against the requested host on every read, so it is useless anywhere else. There is no session, no API access and no other artifact's data at that origin.

The dependency runs the other way too, and it is the one thing a future change must not break:

If the per-artifact origin scheme is ever changed so that several artifacts share a hostname, allow-same-origin must be removed in the same commit. A shared artifact origin plus allow-same-origin means every artifact can read every other artifact's stored data.

allow-scripts together with allow-same-origin also means the frame can remove its own sandbox attribute and reload — a documented browser behaviour, not a bug in enclave. It gains nothing by doing so, because the sandbox is not what isolates artifacts from each other or from the app; the origin is. The sandbox is defence in depth on top of it.

Content-Security-Policy

Two policies, emitted per host, because the two origins need opposite things.

Artifact origin permits 'unsafe-inline' and 'unsafe-eval' in script-src, plus three CDNs (esm.sh, cdn.jsdelivr.net, unpkg.com) so that React-via-import-map artifacts run at all. That is a real widening, and it is acceptable only because the origin holds nothing but the scoped grant cookie. It also sets frame-ancestors to the app origin alone, so no third-party site can frame a private artifact and read it through a user's browser, and base-uri 'none' with form-action 'none'.

App origin gets the opposite: a nonce-based script-src with 'strict-dynamic' and no'unsafe-inline' or 'unsafe-eval', frame-ancestors 'none', X-Frame-Options: DENY, HSTS, and nosniff.

These are set per-host in the request proxy rather than in next.config.ts, because a config-level header rule matches on path only and would put the app's X-Frame-Options: DENY on artifact responses — which would block the viewer's own iframe.

Content-Type on stored objects comes from the extension allowlist and is never sniffed.

Authorization, and the handoff

The artifact origin has no session and makes no authorization decisions of its own. Every decision happens on the app origin, and the artifact origin only trusts a signed token:

  1. A viewer opens /a/{id} (signed in, or with no session at all if the artifact is public) or /s/{token} (a share link) on the app origin.
  2. The app authorizes the read, then mints a handoff token: signed, single-use, 30-second lifetime, bound to a specific artifact, version and viewer.
  3. The viewer page frames https://{id}.artifacts.example.com/__enter?t=….
  4. /__enter validates the token, re-checks authorization — so a share revoked in the seconds since step 2 fails here — and sets a grant cookie for that subdomain only, 30-minute lifetime. Replaying the same token afterwards is refused and the token is burnt; a top-level replay is answered with a redirect to /a/{id}, which re-authorizes from scratch.
  5. The entry document is streamed from object storage through the app process, with authorization re-checked on every single request.
  6. Any other path is authorized from the grant cookie, resolved against the version's manifest by exact match, and answered with a redirect to a freshly minted presigned URL.

Every failure at or below the authorization check on the artifact origin — unauthorized, revoked, wrong host, unknown path — returns the same 404 with the same body. A grant miss above the first database call (no cookie, expired or forged grant, missing or burnt handoff token) is answered by intent: a top-level navigation gets 302 to {APP_URL}/a/{id}; a framed navigation gets a 404 re-entry page with a link to the same URL; a subresource stays the bare 404. That grant-miss answer is identical for any well-formed artifact host, whether or not the artifact exists — the no-existence-oracle invariant — because the origin has not yet consulted the database. Nothing at or below authorization distinguishes "exists but you may not read it" from "does not exist".

Revocation: instant for the document, ≤60 s for assets

This is the sharpest edge of the design, and worth stating precisely.

WhatRevocation delayWhy
The entry documentImmediateIt is proxied by the app on every request, and authorization is re-checked every time. Revoke a share link and the very next document load is a 404.
Assets (JS, CSS, images)Up to PRESIGN_TTL_SECONDS, default 60 secondsAsset bytes are served by object storage, not by the app, via presigned URLs. A URL already issued stays valid until it expires; enclave cannot recall it.

So: revoke a share link and the artifact stops loading at once, but a presigned asset URL captured in the previous minute keeps returning bytes for the rest of its 60 seconds. That is the deliberate trade for not proxying every byte of every asset through the app process. PRESIGN_TTL_SECONDS is configurable — lowering it shortens the window, at the cost of more redirects.

The same bound applies to a deleted artifact: rows and share links go immediately, objects go when the purge job runs past the retention window, and any presigned URL issued before the delete expires within its TTL.

Other controls

AreaWhat is done
Passwordsargon2id (m=19456, t=2, p=1). Sign-in is rate-limited per email and per IP, with one generic failure message that distinguishes nothing.
SessionsHttpOnly, Secure, SameSite=Lax, and host-only — no Domain attribute, so an artifact origin can never see the session cookie. Rotated on sign-in, revocable server-side.
Share and API tokens32 bytes of entropy, stored only as a SHA-256 hash. The plaintext is returned exactly once, at creation, and is unrecoverable afterwards.
User provider keysAES-256-GCM with ENCRYPTION_KEY. Never returned by any endpoint after being stored.
AuthorizationOne function (canRead) is the single read gate for every path, held to 100% branch coverage. Administrators are explicitly excluded from reading private artifacts.
Existence leaksAn unauthorized read is a 404, never a 403. That applies to artifacts, /setup after first run, and /signup without a redeemable invite.
Untrusted inputZod schemas on request bodies and on the environment. Model output goes through a dedicated incremental parser and bundle validator instead, both held to 100% branch coverage: paths are rejected for traversal, absolute paths, backslashes, double slashes, null bytes and disallowed extensions, and only complete file blocks are ever committed — prose outside a block or an unterminated final block persists nothing.
SQLDrizzle with parameterized queries throughout. No string-built SQL.
CSRFSameSite=Lax plus an origin check on state-changing requests.
Audit trailPrivacy changes, share creation and revocation, deletes, restores, purges, token and invite lifecycle, sign-ins and failures, and every non-private view including anonymous ones — with the IP. Rows survive artifact purge.
Log hygienePrompts, tokens, presigned URLs and Authorization headers are never logged. Prompts are never written to the audit log either.
Error hygieneNo stack traces, bucket names or file paths in any client-facing response.

Known limits

Stated plainly, because a limit you know about is not a vulnerability report:

  • Artifacts can reach the network.connect-src is * and three script CDNs are allowed, so a generated artifact can call out to the internet. There is no egress filtering. An artifact is code a user asked a model to write; treat it as such.
  • Rate limits and quotas are per process. They are held in memory, so a multi-replica deployment enforces them per replica rather than globally.
  • X-Forwarded-For is trusted. Every supported deployment puts a TLS-terminating proxy in front, so the first hop of that header is taken as the client IP. Expose the app process directly and the header becomes client-controlled: the per-IP sign-in rate limit can be bypassed and audit rows can be given arbitrary IPs. This is a deployment requirement, documented in docs/self-hosting.md, not a defect to report.
  • Wildcard TLS is your responsibility. Run it over plain http and the origin isolation this whole document rests on does not exist. The app warns at startup; it cannot refuse.
  • No published container image yet. Build from source and verify what you run.
  • v1 has never run in production. The privacy model is covered by 745 unit and integration tests and 95 browser specs, and by scripts/fresh-clone-demo.sh, which drives the whole authorization path end to end. It has no operational track record beyond that.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Security: datj9/enclave

Security

SECURITY.md

Security

Reporting a vulnerability

Report privately, through GitHub's private vulnerability reporting:

https://github.com/datj9/enclave/security/advisories/new

Do not open a public issue, and do not include a working exploit in anything public. If private reporting is unavailable on the repository, contact the maintainer through the address on their GitHub profile (https://github.com/datj9) and say only that you have a security report — details after a private channel is established.

Please include: what you did, what happened, what you expected, and the version or commit you tested. A proof of concept helps; a paragraph of prose about what is theoretically possible usually does not.

What to expect: this is a single-maintainer project with no support contract. You should get an acknowledgement within a week. There is no bug bounty and no reward beyond credit in the advisory, which you can decline. Once a fix is out, the advisory is published — please hold public disclosure until then.

Supported versions

v1 only. There are no earlier releases, and no backports.

The artifact isolation model

This is the part of enclave worth attacking, so here is exactly how it works and where its limits are. Artifacts are HTML, CSS and JavaScript written by a language model from a user's prompt. They are untrusted code that runs in your users' browsers, and the entire design follows from that.

One browser origin per artifact

Every artifact is served from its own hostname:

{artifactId}.artifacts.example.com

The artifactId is a UUID, and a host that merely looks like an artifact origin without a well-formed UUID in that position is not treated as one. The app itself lives on a different hostname, and nothing on the app origin is reachable from an artifact origin.

This is not cosmetic. Because each artifact has its own origin, the browser gives every artifact its own localStorage, IndexedDB, cookie jar and same-origin scope. Artifact A cannot read artifact B's stored data, because to the browser they are unrelated sites.

The sandbox, and why allow-same-origin is here

Artifacts render in an iframe on the app's viewer page:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"

allow-same-origin looks alarming in a sandbox attribute, and usually is. Without it, the browser puts the frame in an opaque origin, where localStorage and IndexedDB throw on access — a large share of generated artifacts store something, so they would simply break.

It is safe here for one specific reason: the origin the frame is allowed to be same-origin with is a hostname belonging to exactly one artifact, and that origin holds nothing worth stealing. The only cookie on it is a grant cookie scoped to that single artifact, whose payload is checked against the requested host on every read, so it is useless anywhere else. There is no session, no API access and no other artifact's data at that origin.

The dependency runs the other way too, and it is the one thing a future change must not break:

If the per-artifact origin scheme is ever changed so that several artifacts share a hostname, allow-same-origin must be removed in the same commit. A shared artifact origin plus allow-same-origin means every artifact can read every other artifact's stored data.

allow-scripts together with allow-same-origin also means the frame can remove its own sandbox attribute and reload — a documented browser behaviour, not a bug in enclave. It gains nothing by doing so, because the sandbox is not what isolates artifacts from each other or from the app; the origin is. The sandbox is defence in depth on top of it.

Content-Security-Policy

Two policies, emitted per host, because the two origins need opposite things.

Artifact origin permits 'unsafe-inline' and 'unsafe-eval' in script-src, plus three CDNs (esm.sh, cdn.jsdelivr.net, unpkg.com) so that React-via-import-map artifacts run at all. That is a real widening, and it is acceptable only because the origin holds nothing but the scoped grant cookie. It also sets frame-ancestors to the app origin alone, so no third-party site can frame a private artifact and read it through a user's browser, and base-uri 'none' with form-action 'none'.

App origin gets the opposite: a nonce-based script-src with 'strict-dynamic' and no'unsafe-inline' or 'unsafe-eval', frame-ancestors 'none', X-Frame-Options: DENY, HSTS, and nosniff.

These are set per-host in the request proxy rather than in next.config.ts, because a config-level header rule matches on path only and would put the app's X-Frame-Options: DENY on artifact responses — which would block the viewer's own iframe.

Content-Type on stored objects comes from the extension allowlist and is never sniffed.

Authorization, and the handoff

The artifact origin has no session and makes no authorization decisions of its own. Every decision happens on the app origin, and the artifact origin only trusts a signed token:

  1. A viewer opens /a/{id} (signed in, or with no session at all if the artifact is public) or /s/{token} (a share link) on the app origin.
  2. The app authorizes the read, then mints a handoff token: signed, single-use, 30-second lifetime, bound to a specific artifact, version and viewer.
  3. The viewer page frames https://{id}.artifacts.example.com/__enter?t=….
  4. /__enter validates the token, re-checks authorization — so a share revoked in the seconds since step 2 fails here — and sets a grant cookie for that subdomain only, 30-minute lifetime. Replaying the same token afterwards is refused and the token is burnt; a top-level replay is answered with a redirect to /a/{id}, which re-authorizes from scratch.
  5. The entry document is streamed from object storage through the app process, with authorization re-checked on every single request.
  6. Any other path is authorized from the grant cookie, resolved against the version's manifest by exact match, and answered with a redirect to a freshly minted presigned URL.

Every failure at or below the authorization check on the artifact origin — unauthorized, revoked, wrong host, unknown path — returns the same 404 with the same body. A grant miss above the first database call (no cookie, expired or forged grant, missing or burnt handoff token) is answered by intent: a top-level navigation gets 302 to {APP_URL}/a/{id}; a framed navigation gets a 404 re-entry page with a link to the same URL; a subresource stays the bare 404. That grant-miss answer is identical for any well-formed artifact host, whether or not the artifact exists — the no-existence-oracle invariant — because the origin has not yet consulted the database. Nothing at or below authorization distinguishes "exists but you may not read it" from "does not exist".

Revocation: instant for the document, ≤60 s for assets

This is the sharpest edge of the design, and worth stating precisely.

WhatRevocation delayWhy
The entry documentImmediateIt is proxied by the app on every request, and authorization is re-checked every time. Revoke a share link and the very next document load is a 404.
Assets (JS, CSS, images)Up to PRESIGN_TTL_SECONDS, default 60 secondsAsset bytes are served by object storage, not by the app, via presigned URLs. A URL already issued stays valid until it expires; enclave cannot recall it.

So: revoke a share link and the artifact stops loading at once, but a presigned asset URL captured in the previous minute keeps returning bytes for the rest of its 60 seconds. That is the deliberate trade for not proxying every byte of every asset through the app process. PRESIGN_TTL_SECONDS is configurable — lowering it shortens the window, at the cost of more redirects.

The same bound applies to a deleted artifact: rows and share links go immediately, objects go when the purge job runs past the retention window, and any presigned URL issued before the delete expires within its TTL.

Other controls

AreaWhat is done
Passwordsargon2id (m=19456, t=2, p=1). Sign-in is rate-limited per email and per IP, with one generic failure message that distinguishes nothing.
SessionsHttpOnly, Secure, SameSite=Lax, and host-only — no Domain attribute, so an artifact origin can never see the session cookie. Rotated on sign-in, revocable server-side.
Share and API tokens32 bytes of entropy, stored only as a SHA-256 hash. The plaintext is returned exactly once, at creation, and is unrecoverable afterwards.
User provider keysAES-256-GCM with ENCRYPTION_KEY. Never returned by any endpoint after being stored.
AuthorizationOne function (canRead) is the single read gate for every path, held to 100% branch coverage. Administrators are explicitly excluded from reading private artifacts.
Existence leaksAn unauthorized read is a 404, never a 403. That applies to artifacts, /setup after first run, and /signup without a redeemable invite.
Untrusted inputZod schemas on request bodies and on the environment. Model output goes through a dedicated incremental parser and bundle validator instead, both held to 100% branch coverage: paths are rejected for traversal, absolute paths, backslashes, double slashes, null bytes and disallowed extensions, and only complete file blocks are ever committed — prose outside a block or an unterminated final block persists nothing.
SQLDrizzle with parameterized queries throughout. No string-built SQL.
CSRFSameSite=Lax plus an origin check on state-changing requests.
Audit trailPrivacy changes, share creation and revocation, deletes, restores, purges, token and invite lifecycle, sign-ins and failures, and every non-private view including anonymous ones — with the IP. Rows survive artifact purge.
Log hygienePrompts, tokens, presigned URLs and Authorization headers are never logged. Prompts are never written to the audit log either.
Error hygieneNo stack traces, bucket names or file paths in any client-facing response.

Known limits

Stated plainly, because a limit you know about is not a vulnerability report:

  • Artifacts can reach the network.connect-src is * and three script CDNs are allowed, so a generated artifact can call out to the internet. There is no egress filtering. An artifact is code a user asked a model to write; treat it as such.
  • Rate limits and quotas are per process. They are held in memory, so a multi-replica deployment enforces them per replica rather than globally.
  • X-Forwarded-For is trusted. Every supported deployment puts a TLS-terminating proxy in front, so the first hop of that header is taken as the client IP. Expose the app process directly and the header becomes client-controlled: the per-IP sign-in rate limit can be bypassed and audit rows can be given arbitrary IPs. This is a deployment requirement, documented in docs/self-hosting.md, not a defect to report.
  • Wildcard TLS is your responsibility. Run it over plain http and the origin isolation this whole document rests on does not exist. The app warns at startup; it cannot refuse.
  • No published container image yet. Build from source and verify what you run.
  • v1 has never run in production. The privacy model is covered by 745 unit and integration tests and 95 browser specs, and by scripts/fresh-clone-demo.sh, which drives the whole authorization path end to end. It has no operational track record beyond that.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Security: datj9/enclave

Security

SECURITY.md

Security

Reporting a vulnerability

Report privately, through GitHub's private vulnerability reporting:

https://github.com/datj9/enclave/security/advisories/new

Do not open a public issue, and do not include a working exploit in anything public. If private reporting is unavailable on the repository, contact the maintainer through the address on their GitHub profile (https://github.com/datj9) and say only that you have a security report — details after a private channel is established.

Please include: what you did, what happened, what you expected, and the version or commit you tested. A proof of concept helps; a paragraph of prose about what is theoretically possible usually does not.

What to expect: this is a single-maintainer project with no support contract. You should get an acknowledgement within a week. There is no bug bounty and no reward beyond credit in the advisory, which you can decline. Once a fix is out, the advisory is published — please hold public disclosure until then.

Supported versions

v1 only. There are no earlier releases, and no backports.

The artifact isolation model

This is the part of enclave worth attacking, so here is exactly how it works and where its limits are. Artifacts are HTML, CSS and JavaScript written by a language model from a user's prompt. They are untrusted code that runs in your users' browsers, and the entire design follows from that.

One browser origin per artifact

Every artifact is served from its own hostname:

{artifactId}.artifacts.example.com

The artifactId is a UUID, and a host that merely looks like an artifact origin without a well-formed UUID in that position is not treated as one. The app itself lives on a different hostname, and nothing on the app origin is reachable from an artifact origin.

This is not cosmetic. Because each artifact has its own origin, the browser gives every artifact its own localStorage, IndexedDB, cookie jar and same-origin scope. Artifact A cannot read artifact B's stored data, because to the browser they are unrelated sites.

The sandbox, and why allow-same-origin is here

Artifacts render in an iframe on the app's viewer page:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"

allow-same-origin looks alarming in a sandbox attribute, and usually is. Without it, the browser puts the frame in an opaque origin, where localStorage and IndexedDB throw on access — a large share of generated artifacts store something, so they would simply break.

It is safe here for one specific reason: the origin the frame is allowed to be same-origin with is a hostname belonging to exactly one artifact, and that origin holds nothing worth stealing. The only cookie on it is a grant cookie scoped to that single artifact, whose payload is checked against the requested host on every read, so it is useless anywhere else. There is no session, no API access and no other artifact's data at that origin.

The dependency runs the other way too, and it is the one thing a future change must not break:

If the per-artifact origin scheme is ever changed so that several artifacts share a hostname, allow-same-origin must be removed in the same commit. A shared artifact origin plus allow-same-origin means every artifact can read every other artifact's stored data.

allow-scripts together with allow-same-origin also means the frame can remove its own sandbox attribute and reload — a documented browser behaviour, not a bug in enclave. It gains nothing by doing so, because the sandbox is not what isolates artifacts from each other or from the app; the origin is. The sandbox is defence in depth on top of it.

Content-Security-Policy

Two policies, emitted per host, because the two origins need opposite things.

Artifact origin permits 'unsafe-inline' and 'unsafe-eval' in script-src, plus three CDNs (esm.sh, cdn.jsdelivr.net, unpkg.com) so that React-via-import-map artifacts run at all. That is a real widening, and it is acceptable only because the origin holds nothing but the scoped grant cookie. It also sets frame-ancestors to the app origin alone, so no third-party site can frame a private artifact and read it through a user's browser, and base-uri 'none' with form-action 'none'.

App origin gets the opposite: a nonce-based script-src with 'strict-dynamic' and no'unsafe-inline' or 'unsafe-eval', frame-ancestors 'none', X-Frame-Options: DENY, HSTS, and nosniff.

These are set per-host in the request proxy rather than in next.config.ts, because a config-level header rule matches on path only and would put the app's X-Frame-Options: DENY on artifact responses — which would block the viewer's own iframe.

Content-Type on stored objects comes from the extension allowlist and is never sniffed.

Authorization, and the handoff

The artifact origin has no session and makes no authorization decisions of its own. Every decision happens on the app origin, and the artifact origin only trusts a signed token:

  1. A viewer opens /a/{id} (signed in, or with no session at all if the artifact is public) or /s/{token} (a share link) on the app origin.
  2. The app authorizes the read, then mints a handoff token: signed, single-use, 30-second lifetime, bound to a specific artifact, version and viewer.
  3. The viewer page frames https://{id}.artifacts.example.com/__enter?t=….
  4. /__enter validates the token, re-checks authorization — so a share revoked in the seconds since step 2 fails here — and sets a grant cookie for that subdomain only, 30-minute lifetime. Replaying the same token afterwards is refused and the token is burnt; a top-level replay is answered with a redirect to /a/{id}, which re-authorizes from scratch.
  5. The entry document is streamed from object storage through the app process, with authorization re-checked on every single request.
  6. Any other path is authorized from the grant cookie, resolved against the version's manifest by exact match, and answered with a redirect to a freshly minted presigned URL.

Every failure at or below the authorization check on the artifact origin — unauthorized, revoked, wrong host, unknown path — returns the same 404 with the same body. A grant miss above the first database call (no cookie, expired or forged grant, missing or burnt handoff token) is answered by intent: a top-level navigation gets 302 to {APP_URL}/a/{id}; a framed navigation gets a 404 re-entry page with a link to the same URL; a subresource stays the bare 404. That grant-miss answer is identical for any well-formed artifact host, whether or not the artifact exists — the no-existence-oracle invariant — because the origin has not yet consulted the database. Nothing at or below authorization distinguishes "exists but you may not read it" from "does not exist".

Revocation: instant for the document, ≤60 s for assets

This is the sharpest edge of the design, and worth stating precisely.

WhatRevocation delayWhy
The entry documentImmediateIt is proxied by the app on every request, and authorization is re-checked every time. Revoke a share link and the very next document load is a 404.
Assets (JS, CSS, images)Up to PRESIGN_TTL_SECONDS, default 60 secondsAsset bytes are served by object storage, not by the app, via presigned URLs. A URL already issued stays valid until it expires; enclave cannot recall it.

So: revoke a share link and the artifact stops loading at once, but a presigned asset URL captured in the previous minute keeps returning bytes for the rest of its 60 seconds. That is the deliberate trade for not proxying every byte of every asset through the app process. PRESIGN_TTL_SECONDS is configurable — lowering it shortens the window, at the cost of more redirects.

The same bound applies to a deleted artifact: rows and share links go immediately, objects go when the purge job runs past the retention window, and any presigned URL issued before the delete expires within its TTL.

Other controls

AreaWhat is done
Passwordsargon2id (m=19456, t=2, p=1). Sign-in is rate-limited per email and per IP, with one generic failure message that distinguishes nothing.
SessionsHttpOnly, Secure, SameSite=Lax, and host-only — no Domain attribute, so an artifact origin can never see the session cookie. Rotated on sign-in, revocable server-side.
Share and API tokens32 bytes of entropy, stored only as a SHA-256 hash. The plaintext is returned exactly once, at creation, and is unrecoverable afterwards.
User provider keysAES-256-GCM with ENCRYPTION_KEY. Never returned by any endpoint after being stored.
AuthorizationOne function (canRead) is the single read gate for every path, held to 100% branch coverage. Administrators are explicitly excluded from reading private artifacts.
Existence leaksAn unauthorized read is a 404, never a 403. That applies to artifacts, /setup after first run, and /signup without a redeemable invite.
Untrusted inputZod schemas on request bodies and on the environment. Model output goes through a dedicated incremental parser and bundle validator instead, both held to 100% branch coverage: paths are rejected for traversal, absolute paths, backslashes, double slashes, null bytes and disallowed extensions, and only complete file blocks are ever committed — prose outside a block or an unterminated final block persists nothing.
SQLDrizzle with parameterized queries throughout. No string-built SQL.
CSRFSameSite=Lax plus an origin check on state-changing requests.
Audit trailPrivacy changes, share creation and revocation, deletes, restores, purges, token and invite lifecycle, sign-ins and failures, and every non-private view including anonymous ones — with the IP. Rows survive artifact purge.
Log hygienePrompts, tokens, presigned URLs and Authorization headers are never logged. Prompts are never written to the audit log either.
Error hygieneNo stack traces, bucket names or file paths in any client-facing response.

Known limits

Stated plainly, because a limit you know about is not a vulnerability report:

  • Artifacts can reach the network.connect-src is * and three script CDNs are allowed, so a generated artifact can call out to the internet. There is no egress filtering. An artifact is code a user asked a model to write; treat it as such.
  • Rate limits and quotas are per process. They are held in memory, so a multi-replica deployment enforces them per replica rather than globally.
  • X-Forwarded-For is trusted. Every supported deployment puts a TLS-terminating proxy in front, so the first hop of that header is taken as the client IP. Expose the app process directly and the header becomes client-controlled: the per-IP sign-in rate limit can be bypassed and audit rows can be given arbitrary IPs. This is a deployment requirement, documented in docs/self-hosting.md, not a defect to report.
  • Wildcard TLS is your responsibility. Run it over plain http and the origin isolation this whole document rests on does not exist. The app warns at startup; it cannot refuse.
  • No published container image yet. Build from source and verify what you run.
  • v1 has never run in production. The privacy model is covered by 745 unit and integration tests and 95 browser specs, and by scripts/fresh-clone-demo.sh, which drives the whole authorization path end to end. It has no operational track record beyond that.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Security: datj9/enclave

Security

SECURITY.md

Security

Reporting a vulnerability

Report privately, through GitHub's private vulnerability reporting:

https://github.com/datj9/enclave/security/advisories/new

Do not open a public issue, and do not include a working exploit in anything public. If private reporting is unavailable on the repository, contact the maintainer through the address on their GitHub profile (https://github.com/datj9) and say only that you have a security report — details after a private channel is established.

Please include: what you did, what happened, what you expected, and the version or commit you tested. A proof of concept helps; a paragraph of prose about what is theoretically possible usually does not.

What to expect: this is a single-maintainer project with no support contract. You should get an acknowledgement within a week. There is no bug bounty and no reward beyond credit in the advisory, which you can decline. Once a fix is out, the advisory is published — please hold public disclosure until then.

Supported versions

v1 only. There are no earlier releases, and no backports.

The artifact isolation model

This is the part of enclave worth attacking, so here is exactly how it works and where its limits are. Artifacts are HTML, CSS and JavaScript written by a language model from a user's prompt. They are untrusted code that runs in your users' browsers, and the entire design follows from that.

One browser origin per artifact

Every artifact is served from its own hostname:

{artifactId}.artifacts.example.com

The artifactId is a UUID, and a host that merely looks like an artifact origin without a well-formed UUID in that position is not treated as one. The app itself lives on a different hostname, and nothing on the app origin is reachable from an artifact origin.

This is not cosmetic. Because each artifact has its own origin, the browser gives every artifact its own localStorage, IndexedDB, cookie jar and same-origin scope. Artifact A cannot read artifact B's stored data, because to the browser they are unrelated sites.

The sandbox, and why allow-same-origin is here

Artifacts render in an iframe on the app's viewer page:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"

allow-same-origin looks alarming in a sandbox attribute, and usually is. Without it, the browser puts the frame in an opaque origin, where localStorage and IndexedDB throw on access — a large share of generated artifacts store something, so they would simply break.

It is safe here for one specific reason: the origin the frame is allowed to be same-origin with is a hostname belonging to exactly one artifact, and that origin holds nothing worth stealing. The only cookie on it is a grant cookie scoped to that single artifact, whose payload is checked against the requested host on every read, so it is useless anywhere else. There is no session, no API access and no other artifact's data at that origin.

The dependency runs the other way too, and it is the one thing a future change must not break:

If the per-artifact origin scheme is ever changed so that several artifacts share a hostname, allow-same-origin must be removed in the same commit. A shared artifact origin plus allow-same-origin means every artifact can read every other artifact's stored data.

allow-scripts together with allow-same-origin also means the frame can remove its own sandbox attribute and reload — a documented browser behaviour, not a bug in enclave. It gains nothing by doing so, because the sandbox is not what isolates artifacts from each other or from the app; the origin is. The sandbox is defence in depth on top of it.

Content-Security-Policy

Two policies, emitted per host, because the two origins need opposite things.

Artifact origin permits 'unsafe-inline' and 'unsafe-eval' in script-src, plus three CDNs (esm.sh, cdn.jsdelivr.net, unpkg.com) so that React-via-import-map artifacts run at all. That is a real widening, and it is acceptable only because the origin holds nothing but the scoped grant cookie. It also sets frame-ancestors to the app origin alone, so no third-party site can frame a private artifact and read it through a user's browser, and base-uri 'none' with form-action 'none'.

App origin gets the opposite: a nonce-based script-src with 'strict-dynamic' and no'unsafe-inline' or 'unsafe-eval', frame-ancestors 'none', X-Frame-Options: DENY, HSTS, and nosniff.

These are set per-host in the request proxy rather than in next.config.ts, because a config-level header rule matches on path only and would put the app's X-Frame-Options: DENY on artifact responses — which would block the viewer's own iframe.

Content-Type on stored objects comes from the extension allowlist and is never sniffed.

Authorization, and the handoff

The artifact origin has no session and makes no authorization decisions of its own. Every decision happens on the app origin, and the artifact origin only trusts a signed token:

  1. A viewer opens /a/{id} (signed in, or with no session at all if the artifact is public) or /s/{token} (a share link) on the app origin.
  2. The app authorizes the read, then mints a handoff token: signed, single-use, 30-second lifetime, bound to a specific artifact, version and viewer.
  3. The viewer page frames https://{id}.artifacts.example.com/__enter?t=….
  4. /__enter validates the token, re-checks authorization — so a share revoked in the seconds since step 2 fails here — and sets a grant cookie for that subdomain only, 30-minute lifetime. Replaying the same token afterwards is refused and the token is burnt; a top-level replay is answered with a redirect to /a/{id}, which re-authorizes from scratch.
  5. The entry document is streamed from object storage through the app process, with authorization re-checked on every single request.
  6. Any other path is authorized from the grant cookie, resolved against the version's manifest by exact match, and answered with a redirect to a freshly minted presigned URL.

Every failure at or below the authorization check on the artifact origin — unauthorized, revoked, wrong host, unknown path — returns the same 404 with the same body. A grant miss above the first database call (no cookie, expired or forged grant, missing or burnt handoff token) is answered by intent: a top-level navigation gets 302 to {APP_URL}/a/{id}; a framed navigation gets a 404 re-entry page with a link to the same URL; a subresource stays the bare 404. That grant-miss answer is identical for any well-formed artifact host, whether or not the artifact exists — the no-existence-oracle invariant — because the origin has not yet consulted the database. Nothing at or below authorization distinguishes "exists but you may not read it" from "does not exist".

Revocation: instant for the document, ≤60 s for assets

This is the sharpest edge of the design, and worth stating precisely.

WhatRevocation delayWhy
The entry documentImmediateIt is proxied by the app on every request, and authorization is re-checked every time. Revoke a share link and the very next document load is a 404.
Assets (JS, CSS, images)Up to PRESIGN_TTL_SECONDS, default 60 secondsAsset bytes are served by object storage, not by the app, via presigned URLs. A URL already issued stays valid until it expires; enclave cannot recall it.

So: revoke a share link and the artifact stops loading at once, but a presigned asset URL captured in the previous minute keeps returning bytes for the rest of its 60 seconds. That is the deliberate trade for not proxying every byte of every asset through the app process. PRESIGN_TTL_SECONDS is configurable — lowering it shortens the window, at the cost of more redirects.

The same bound applies to a deleted artifact: rows and share links go immediately, objects go when the purge job runs past the retention window, and any presigned URL issued before the delete expires within its TTL.

Other controls

AreaWhat is done
Passwordsargon2id (m=19456, t=2, p=1). Sign-in is rate-limited per email and per IP, with one generic failure message that distinguishes nothing.
SessionsHttpOnly, Secure, SameSite=Lax, and host-only — no Domain attribute, so an artifact origin can never see the session cookie. Rotated on sign-in, revocable server-side.
Share and API tokens32 bytes of entropy, stored only as a SHA-256 hash. The plaintext is returned exactly once, at creation, and is unrecoverable afterwards.
User provider keysAES-256-GCM with ENCRYPTION_KEY. Never returned by any endpoint after being stored.
AuthorizationOne function (canRead) is the single read gate for every path, held to 100% branch coverage. Administrators are explicitly excluded from reading private artifacts.
Existence leaksAn unauthorized read is a 404, never a 403. That applies to artifacts, /setup after first run, and /signup without a redeemable invite.
Untrusted inputZod schemas on request bodies and on the environment. Model output goes through a dedicated incremental parser and bundle validator instead, both held to 100% branch coverage: paths are rejected for traversal, absolute paths, backslashes, double slashes, null bytes and disallowed extensions, and only complete file blocks are ever committed — prose outside a block or an unterminated final block persists nothing.
SQLDrizzle with parameterized queries throughout. No string-built SQL.
CSRFSameSite=Lax plus an origin check on state-changing requests.
Audit trailPrivacy changes, share creation and revocation, deletes, restores, purges, token and invite lifecycle, sign-ins and failures, and every non-private view including anonymous ones — with the IP. Rows survive artifact purge.
Log hygienePrompts, tokens, presigned URLs and Authorization headers are never logged. Prompts are never written to the audit log either.
Error hygieneNo stack traces, bucket names or file paths in any client-facing response.

Known limits

Stated plainly, because a limit you know about is not a vulnerability report:

  • Artifacts can reach the network.connect-src is * and three script CDNs are allowed, so a generated artifact can call out to the internet. There is no egress filtering. An artifact is code a user asked a model to write; treat it as such.
  • Rate limits and quotas are per process. They are held in memory, so a multi-replica deployment enforces them per replica rather than globally.
  • X-Forwarded-For is trusted. Every supported deployment puts a TLS-terminating proxy in front, so the first hop of that header is taken as the client IP. Expose the app process directly and the header becomes client-controlled: the per-IP sign-in rate limit can be bypassed and audit rows can be given arbitrary IPs. This is a deployment requirement, documented in docs/self-hosting.md, not a defect to report.
  • Wildcard TLS is your responsibility. Run it over plain http and the origin isolation this whole document rests on does not exist. The app warns at startup; it cannot refuse.
  • No published container image yet. Build from source and verify what you run.
  • v1 has never run in production. The privacy model is covered by 745 unit and integration tests and 95 browser specs, and by scripts/fresh-clone-demo.sh, which drives the whole authorization path end to end. It has no operational track record beyond that.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Security: datj9/enclave

Security

SECURITY.md

Security

Reporting a vulnerability

Report privately, through GitHub's private vulnerability reporting:

https://github.com/datj9/enclave/security/advisories/new

Do not open a public issue, and do not include a working exploit in anything public. If private reporting is unavailable on the repository, contact the maintainer through the address on their GitHub profile (https://github.com/datj9) and say only that you have a security report — details after a private channel is established.

Please include: what you did, what happened, what you expected, and the version or commit you tested. A proof of concept helps; a paragraph of prose about what is theoretically possible usually does not.

What to expect: this is a single-maintainer project with no support contract. You should get an acknowledgement within a week. There is no bug bounty and no reward beyond credit in the advisory, which you can decline. Once a fix is out, the advisory is published — please hold public disclosure until then.

Supported versions

v1 only. There are no earlier releases, and no backports.

The artifact isolation model

This is the part of enclave worth attacking, so here is exactly how it works and where its limits are. Artifacts are HTML, CSS and JavaScript written by a language model from a user's prompt. They are untrusted code that runs in your users' browsers, and the entire design follows from that.

One browser origin per artifact

Every artifact is served from its own hostname:

{artifactId}.artifacts.example.com

The artifactId is a UUID, and a host that merely looks like an artifact origin without a well-formed UUID in that position is not treated as one. The app itself lives on a different hostname, and nothing on the app origin is reachable from an artifact origin.

This is not cosmetic. Because each artifact has its own origin, the browser gives every artifact its own localStorage, IndexedDB, cookie jar and same-origin scope. Artifact A cannot read artifact B's stored data, because to the browser they are unrelated sites.

The sandbox, and why allow-same-origin is here

Artifacts render in an iframe on the app's viewer page:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"

allow-same-origin looks alarming in a sandbox attribute, and usually is. Without it, the browser puts the frame in an opaque origin, where localStorage and IndexedDB throw on access — a large share of generated artifacts store something, so they would simply break.

It is safe here for one specific reason: the origin the frame is allowed to be same-origin with is a hostname belonging to exactly one artifact, and that origin holds nothing worth stealing. The only cookie on it is a grant cookie scoped to that single artifact, whose payload is checked against the requested host on every read, so it is useless anywhere else. There is no session, no API access and no other artifact's data at that origin.

The dependency runs the other way too, and it is the one thing a future change must not break:

If the per-artifact origin scheme is ever changed so that several artifacts share a hostname, allow-same-origin must be removed in the same commit. A shared artifact origin plus allow-same-origin means every artifact can read every other artifact's stored data.

allow-scripts together with allow-same-origin also means the frame can remove its own sandbox attribute and reload — a documented browser behaviour, not a bug in enclave. It gains nothing by doing so, because the sandbox is not what isolates artifacts from each other or from the app; the origin is. The sandbox is defence in depth on top of it.

Content-Security-Policy

Two policies, emitted per host, because the two origins need opposite things.

Artifact origin permits 'unsafe-inline' and 'unsafe-eval' in script-src, plus three CDNs (esm.sh, cdn.jsdelivr.net, unpkg.com) so that React-via-import-map artifacts run at all. That is a real widening, and it is acceptable only because the origin holds nothing but the scoped grant cookie. It also sets frame-ancestors to the app origin alone, so no third-party site can frame a private artifact and read it through a user's browser, and base-uri 'none' with form-action 'none'.

App origin gets the opposite: a nonce-based script-src with 'strict-dynamic' and no'unsafe-inline' or 'unsafe-eval', frame-ancestors 'none', X-Frame-Options: DENY, HSTS, and nosniff.

These are set per-host in the request proxy rather than in next.config.ts, because a config-level header rule matches on path only and would put the app's X-Frame-Options: DENY on artifact responses — which would block the viewer's own iframe.

Content-Type on stored objects comes from the extension allowlist and is never sniffed.

Authorization, and the handoff

The artifact origin has no session and makes no authorization decisions of its own. Every decision happens on the app origin, and the artifact origin only trusts a signed token:

  1. A viewer opens /a/{id} (signed in, or with no session at all if the artifact is public) or /s/{token} (a share link) on the app origin.
  2. The app authorizes the read, then mints a handoff token: signed, single-use, 30-second lifetime, bound to a specific artifact, version and viewer.
  3. The viewer page frames https://{id}.artifacts.example.com/__enter?t=….
  4. /__enter validates the token, re-checks authorization — so a share revoked in the seconds since step 2 fails here — and sets a grant cookie for that subdomain only, 30-minute lifetime. Replaying the same token afterwards is refused and the token is burnt; a top-level replay is answered with a redirect to /a/{id}, which re-authorizes from scratch.
  5. The entry document is streamed from object storage through the app process, with authorization re-checked on every single request.
  6. Any other path is authorized from the grant cookie, resolved against the version's manifest by exact match, and answered with a redirect to a freshly minted presigned URL.

Every failure at or below the authorization check on the artifact origin — unauthorized, revoked, wrong host, unknown path — returns the same 404 with the same body. A grant miss above the first database call (no cookie, expired or forged grant, missing or burnt handoff token) is answered by intent: a top-level navigation gets 302 to {APP_URL}/a/{id}; a framed navigation gets a 404 re-entry page with a link to the same URL; a subresource stays the bare 404. That grant-miss answer is identical for any well-formed artifact host, whether or not the artifact exists — the no-existence-oracle invariant — because the origin has not yet consulted the database. Nothing at or below authorization distinguishes "exists but you may not read it" from "does not exist".

Revocation: instant for the document, ≤60 s for assets

This is the sharpest edge of the design, and worth stating precisely.

WhatRevocation delayWhy
The entry documentImmediateIt is proxied by the app on every request, and authorization is re-checked every time. Revoke a share link and the very next document load is a 404.
Assets (JS, CSS, images)Up to PRESIGN_TTL_SECONDS, default 60 secondsAsset bytes are served by object storage, not by the app, via presigned URLs. A URL already issued stays valid until it expires; enclave cannot recall it.

So: revoke a share link and the artifact stops loading at once, but a presigned asset URL captured in the previous minute keeps returning bytes for the rest of its 60 seconds. That is the deliberate trade for not proxying every byte of every asset through the app process. PRESIGN_TTL_SECONDS is configurable — lowering it shortens the window, at the cost of more redirects.

The same bound applies to a deleted artifact: rows and share links go immediately, objects go when the purge job runs past the retention window, and any presigned URL issued before the delete expires within its TTL.

Other controls

AreaWhat is done
Passwordsargon2id (m=19456, t=2, p=1). Sign-in is rate-limited per email and per IP, with one generic failure message that distinguishes nothing.
SessionsHttpOnly, Secure, SameSite=Lax, and host-only — no Domain attribute, so an artifact origin can never see the session cookie. Rotated on sign-in, revocable server-side.
Share and API tokens32 bytes of entropy, stored only as a SHA-256 hash. The plaintext is returned exactly once, at creation, and is unrecoverable afterwards.
User provider keysAES-256-GCM with ENCRYPTION_KEY. Never returned by any endpoint after being stored.
AuthorizationOne function (canRead) is the single read gate for every path, held to 100% branch coverage. Administrators are explicitly excluded from reading private artifacts.
Existence leaksAn unauthorized read is a 404, never a 403. That applies to artifacts, /setup after first run, and /signup without a redeemable invite.
Untrusted inputZod schemas on request bodies and on the environment. Model output goes through a dedicated incremental parser and bundle validator instead, both held to 100% branch coverage: paths are rejected for traversal, absolute paths, backslashes, double slashes, null bytes and disallowed extensions, and only complete file blocks are ever committed — prose outside a block or an unterminated final block persists nothing.
SQLDrizzle with parameterized queries throughout. No string-built SQL.
CSRFSameSite=Lax plus an origin check on state-changing requests.
Audit trailPrivacy changes, share creation and revocation, deletes, restores, purges, token and invite lifecycle, sign-ins and failures, and every non-private view including anonymous ones — with the IP. Rows survive artifact purge.
Log hygienePrompts, tokens, presigned URLs and Authorization headers are never logged. Prompts are never written to the audit log either.
Error hygieneNo stack traces, bucket names or file paths in any client-facing response.

Known limits

Stated plainly, because a limit you know about is not a vulnerability report:

  • Artifacts can reach the network.connect-src is * and three script CDNs are allowed, so a generated artifact can call out to the internet. There is no egress filtering. An artifact is code a user asked a model to write; treat it as such.
  • Rate limits and quotas are per process. They are held in memory, so a multi-replica deployment enforces them per replica rather than globally.
  • X-Forwarded-For is trusted. Every supported deployment puts a TLS-terminating proxy in front, so the first hop of that header is taken as the client IP. Expose the app process directly and the header becomes client-controlled: the per-IP sign-in rate limit can be bypassed and audit rows can be given arbitrary IPs. This is a deployment requirement, documented in docs/self-hosting.md, not a defect to report.
  • Wildcard TLS is your responsibility. Run it over plain http and the origin isolation this whole document rests on does not exist. The app warns at startup; it cannot refuse.
  • No published container image yet. Build from source and verify what you run.
  • v1 has never run in production. The privacy model is covered by 745 unit and integration tests and 95 browser specs, and by scripts/fresh-clone-demo.sh, which drives the whole authorization path end to end. It has no operational track record beyond that.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Security: datj9/enclave

Security

SECURITY.md

Security

Reporting a vulnerability

Report privately, through GitHub's private vulnerability reporting:

https://github.com/datj9/enclave/security/advisories/new

Do not open a public issue, and do not include a working exploit in anything public. If private reporting is unavailable on the repository, contact the maintainer through the address on their GitHub profile (https://github.com/datj9) and say only that you have a security report — details after a private channel is established.

Please include: what you did, what happened, what you expected, and the version or commit you tested. A proof of concept helps; a paragraph of prose about what is theoretically possible usually does not.

What to expect: this is a single-maintainer project with no support contract. You should get an acknowledgement within a week. There is no bug bounty and no reward beyond credit in the advisory, which you can decline. Once a fix is out, the advisory is published — please hold public disclosure until then.

Supported versions

v1 only. There are no earlier releases, and no backports.

The artifact isolation model

This is the part of enclave worth attacking, so here is exactly how it works and where its limits are. Artifacts are HTML, CSS and JavaScript written by a language model from a user's prompt. They are untrusted code that runs in your users' browsers, and the entire design follows from that.

One browser origin per artifact

Every artifact is served from its own hostname:

{artifactId}.artifacts.example.com

The artifactId is a UUID, and a host that merely looks like an artifact origin without a well-formed UUID in that position is not treated as one. The app itself lives on a different hostname, and nothing on the app origin is reachable from an artifact origin.

This is not cosmetic. Because each artifact has its own origin, the browser gives every artifact its own localStorage, IndexedDB, cookie jar and same-origin scope. Artifact A cannot read artifact B's stored data, because to the browser they are unrelated sites.

The sandbox, and why allow-same-origin is here

Artifacts render in an iframe on the app's viewer page:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"

allow-same-origin looks alarming in a sandbox attribute, and usually is. Without it, the browser puts the frame in an opaque origin, where localStorage and IndexedDB throw on access — a large share of generated artifacts store something, so they would simply break.

It is safe here for one specific reason: the origin the frame is allowed to be same-origin with is a hostname belonging to exactly one artifact, and that origin holds nothing worth stealing. The only cookie on it is a grant cookie scoped to that single artifact, whose payload is checked against the requested host on every read, so it is useless anywhere else. There is no session, no API access and no other artifact's data at that origin.

The dependency runs the other way too, and it is the one thing a future change must not break:

If the per-artifact origin scheme is ever changed so that several artifacts share a hostname, allow-same-origin must be removed in the same commit. A shared artifact origin plus allow-same-origin means every artifact can read every other artifact's stored data.

allow-scripts together with allow-same-origin also means the frame can remove its own sandbox attribute and reload — a documented browser behaviour, not a bug in enclave. It gains nothing by doing so, because the sandbox is not what isolates artifacts from each other or from the app; the origin is. The sandbox is defence in depth on top of it.

Content-Security-Policy

Two policies, emitted per host, because the two origins need opposite things.

Artifact origin permits 'unsafe-inline' and 'unsafe-eval' in script-src, plus three CDNs (esm.sh, cdn.jsdelivr.net, unpkg.com) so that React-via-import-map artifacts run at all. That is a real widening, and it is acceptable only because the origin holds nothing but the scoped grant cookie. It also sets frame-ancestors to the app origin alone, so no third-party site can frame a private artifact and read it through a user's browser, and base-uri 'none' with form-action 'none'.

App origin gets the opposite: a nonce-based script-src with 'strict-dynamic' and no'unsafe-inline' or 'unsafe-eval', frame-ancestors 'none', X-Frame-Options: DENY, HSTS, and nosniff.

These are set per-host in the request proxy rather than in next.config.ts, because a config-level header rule matches on path only and would put the app's X-Frame-Options: DENY on artifact responses — which would block the viewer's own iframe.

Content-Type on stored objects comes from the extension allowlist and is never sniffed.

Authorization, and the handoff

The artifact origin has no session and makes no authorization decisions of its own. Every decision happens on the app origin, and the artifact origin only trusts a signed token:

  1. A viewer opens /a/{id} (signed in, or with no session at all if the artifact is public) or /s/{token} (a share link) on the app origin.
  2. The app authorizes the read, then mints a handoff token: signed, single-use, 30-second lifetime, bound to a specific artifact, version and viewer.
  3. The viewer page frames https://{id}.artifacts.example.com/__enter?t=….
  4. /__enter validates the token, re-checks authorization — so a share revoked in the seconds since step 2 fails here — and sets a grant cookie for that subdomain only, 30-minute lifetime. Replaying the same token afterwards is refused and the token is burnt; a top-level replay is answered with a redirect to /a/{id}, which re-authorizes from scratch.
  5. The entry document is streamed from object storage through the app process, with authorization re-checked on every single request.
  6. Any other path is authorized from the grant cookie, resolved against the version's manifest by exact match, and answered with a redirect to a freshly minted presigned URL.

Every failure at or below the authorization check on the artifact origin — unauthorized, revoked, wrong host, unknown path — returns the same 404 with the same body. A grant miss above the first database call (no cookie, expired or forged grant, missing or burnt handoff token) is answered by intent: a top-level navigation gets 302 to {APP_URL}/a/{id}; a framed navigation gets a 404 re-entry page with a link to the same URL; a subresource stays the bare 404. That grant-miss answer is identical for any well-formed artifact host, whether or not the artifact exists — the no-existence-oracle invariant — because the origin has not yet consulted the database. Nothing at or below authorization distinguishes "exists but you may not read it" from "does not exist".

Revocation: instant for the document, ≤60 s for assets

This is the sharpest edge of the design, and worth stating precisely.

WhatRevocation delayWhy
The entry documentImmediateIt is proxied by the app on every request, and authorization is re-checked every time. Revoke a share link and the very next document load is a 404.
Assets (JS, CSS, images)Up to PRESIGN_TTL_SECONDS, default 60 secondsAsset bytes are served by object storage, not by the app, via presigned URLs. A URL already issued stays valid until it expires; enclave cannot recall it.

So: revoke a share link and the artifact stops loading at once, but a presigned asset URL captured in the previous minute keeps returning bytes for the rest of its 60 seconds. That is the deliberate trade for not proxying every byte of every asset through the app process. PRESIGN_TTL_SECONDS is configurable — lowering it shortens the window, at the cost of more redirects.

The same bound applies to a deleted artifact: rows and share links go immediately, objects go when the purge job runs past the retention window, and any presigned URL issued before the delete expires within its TTL.

Other controls

AreaWhat is done
Passwordsargon2id (m=19456, t=2, p=1). Sign-in is rate-limited per email and per IP, with one generic failure message that distinguishes nothing.
SessionsHttpOnly, Secure, SameSite=Lax, and host-only — no Domain attribute, so an artifact origin can never see the session cookie. Rotated on sign-in, revocable server-side.
Share and API tokens32 bytes of entropy, stored only as a SHA-256 hash. The plaintext is returned exactly once, at creation, and is unrecoverable afterwards.
User provider keysAES-256-GCM with ENCRYPTION_KEY. Never returned by any endpoint after being stored.
AuthorizationOne function (canRead) is the single read gate for every path, held to 100% branch coverage. Administrators are explicitly excluded from reading private artifacts.
Existence leaksAn unauthorized read is a 404, never a 403. That applies to artifacts, /setup after first run, and /signup without a redeemable invite.
Untrusted inputZod schemas on request bodies and on the environment. Model output goes through a dedicated incremental parser and bundle validator instead, both held to 100% branch coverage: paths are rejected for traversal, absolute paths, backslashes, double slashes, null bytes and disallowed extensions, and only complete file blocks are ever committed — prose outside a block or an unterminated final block persists nothing.
SQLDrizzle with parameterized queries throughout. No string-built SQL.
CSRFSameSite=Lax plus an origin check on state-changing requests.
Audit trailPrivacy changes, share creation and revocation, deletes, restores, purges, token and invite lifecycle, sign-ins and failures, and every non-private view including anonymous ones — with the IP. Rows survive artifact purge.
Log hygienePrompts, tokens, presigned URLs and Authorization headers are never logged. Prompts are never written to the audit log either.
Error hygieneNo stack traces, bucket names or file paths in any client-facing response.

Known limits

Stated plainly, because a limit you know about is not a vulnerability report:

  • Artifacts can reach the network.connect-src is * and three script CDNs are allowed, so a generated artifact can call out to the internet. There is no egress filtering. An artifact is code a user asked a model to write; treat it as such.
  • Rate limits and quotas are per process. They are held in memory, so a multi-replica deployment enforces them per replica rather than globally.
  • X-Forwarded-For is trusted. Every supported deployment puts a TLS-terminating proxy in front, so the first hop of that header is taken as the client IP. Expose the app process directly and the header becomes client-controlled: the per-IP sign-in rate limit can be bypassed and audit rows can be given arbitrary IPs. This is a deployment requirement, documented in docs/self-hosting.md, not a defect to report.
  • Wildcard TLS is your responsibility. Run it over plain http and the origin isolation this whole document rests on does not exist. The app warns at startup; it cannot refuse.
  • No published container image yet. Build from source and verify what you run.
  • v1 has never run in production. The privacy model is covered by 745 unit and integration tests and 95 browser specs, and by scripts/fresh-clone-demo.sh, which drives the whole authorization path end to end. It has no operational track record beyond that.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Security: datj9/enclave

Security

SECURITY.md

Security

Reporting a vulnerability

Report privately, through GitHub's private vulnerability reporting:

https://github.com/datj9/enclave/security/advisories/new

Do not open a public issue, and do not include a working exploit in anything public. If private reporting is unavailable on the repository, contact the maintainer through the address on their GitHub profile (https://github.com/datj9) and say only that you have a security report — details after a private channel is established.

Please include: what you did, what happened, what you expected, and the version or commit you tested. A proof of concept helps; a paragraph of prose about what is theoretically possible usually does not.

What to expect: this is a single-maintainer project with no support contract. You should get an acknowledgement within a week. There is no bug bounty and no reward beyond credit in the advisory, which you can decline. Once a fix is out, the advisory is published — please hold public disclosure until then.

Supported versions

v1 only. There are no earlier releases, and no backports.

The artifact isolation model

This is the part of enclave worth attacking, so here is exactly how it works and where its limits are. Artifacts are HTML, CSS and JavaScript written by a language model from a user's prompt. They are untrusted code that runs in your users' browsers, and the entire design follows from that.

One browser origin per artifact

Every artifact is served from its own hostname:

{artifactId}.artifacts.example.com

The artifactId is a UUID, and a host that merely looks like an artifact origin without a well-formed UUID in that position is not treated as one. The app itself lives on a different hostname, and nothing on the app origin is reachable from an artifact origin.

This is not cosmetic. Because each artifact has its own origin, the browser gives every artifact its own localStorage, IndexedDB, cookie jar and same-origin scope. Artifact A cannot read artifact B's stored data, because to the browser they are unrelated sites.

The sandbox, and why allow-same-origin is here

Artifacts render in an iframe on the app's viewer page:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"

allow-same-origin looks alarming in a sandbox attribute, and usually is. Without it, the browser puts the frame in an opaque origin, where localStorage and IndexedDB throw on access — a large share of generated artifacts store something, so they would simply break.

It is safe here for one specific reason: the origin the frame is allowed to be same-origin with is a hostname belonging to exactly one artifact, and that origin holds nothing worth stealing. The only cookie on it is a grant cookie scoped to that single artifact, whose payload is checked against the requested host on every read, so it is useless anywhere else. There is no session, no API access and no other artifact's data at that origin.

The dependency runs the other way too, and it is the one thing a future change must not break:

If the per-artifact origin scheme is ever changed so that several artifacts share a hostname, allow-same-origin must be removed in the same commit. A shared artifact origin plus allow-same-origin means every artifact can read every other artifact's stored data.

allow-scripts together with allow-same-origin also means the frame can remove its own sandbox attribute and reload — a documented browser behaviour, not a bug in enclave. It gains nothing by doing so, because the sandbox is not what isolates artifacts from each other or from the app; the origin is. The sandbox is defence in depth on top of it.

Content-Security-Policy

Two policies, emitted per host, because the two origins need opposite things.

Artifact origin permits 'unsafe-inline' and 'unsafe-eval' in script-src, plus three CDNs (esm.sh, cdn.jsdelivr.net, unpkg.com) so that React-via-import-map artifacts run at all. That is a real widening, and it is acceptable only because the origin holds nothing but the scoped grant cookie. It also sets frame-ancestors to the app origin alone, so no third-party site can frame a private artifact and read it through a user's browser, and base-uri 'none' with form-action 'none'.

App origin gets the opposite: a nonce-based script-src with 'strict-dynamic' and no'unsafe-inline' or 'unsafe-eval', frame-ancestors 'none', X-Frame-Options: DENY, HSTS, and nosniff.

These are set per-host in the request proxy rather than in next.config.ts, because a config-level header rule matches on path only and would put the app's X-Frame-Options: DENY on artifact responses — which would block the viewer's own iframe.

Content-Type on stored objects comes from the extension allowlist and is never sniffed.

Authorization, and the handoff

The artifact origin has no session and makes no authorization decisions of its own. Every decision happens on the app origin, and the artifact origin only trusts a signed token:

  1. A viewer opens /a/{id} (signed in, or with no session at all if the artifact is public) or /s/{token} (a share link) on the app origin.
  2. The app authorizes the read, then mints a handoff token: signed, single-use, 30-second lifetime, bound to a specific artifact, version and viewer.
  3. The viewer page frames https://{id}.artifacts.example.com/__enter?t=….
  4. /__enter validates the token, re-checks authorization — so a share revoked in the seconds since step 2 fails here — and sets a grant cookie for that subdomain only, 30-minute lifetime. Replaying the same token afterwards is refused and the token is burnt; a top-level replay is answered with a redirect to /a/{id}, which re-authorizes from scratch.
  5. The entry document is streamed from object storage through the app process, with authorization re-checked on every single request.
  6. Any other path is authorized from the grant cookie, resolved against the version's manifest by exact match, and answered with a redirect to a freshly minted presigned URL.

Every failure at or below the authorization check on the artifact origin — unauthorized, revoked, wrong host, unknown path — returns the same 404 with the same body. A grant miss above the first database call (no cookie, expired or forged grant, missing or burnt handoff token) is answered by intent: a top-level navigation gets 302 to {APP_URL}/a/{id}; a framed navigation gets a 404 re-entry page with a link to the same URL; a subresource stays the bare 404. That grant-miss answer is identical for any well-formed artifact host, whether or not the artifact exists — the no-existence-oracle invariant — because the origin has not yet consulted the database. Nothing at or below authorization distinguishes "exists but you may not read it" from "does not exist".

Revocation: instant for the document, ≤60 s for assets

This is the sharpest edge of the design, and worth stating precisely.

WhatRevocation delayWhy
The entry documentImmediateIt is proxied by the app on every request, and authorization is re-checked every time. Revoke a share link and the very next document load is a 404.
Assets (JS, CSS, images)Up to PRESIGN_TTL_SECONDS, default 60 secondsAsset bytes are served by object storage, not by the app, via presigned URLs. A URL already issued stays valid until it expires; enclave cannot recall it.

So: revoke a share link and the artifact stops loading at once, but a presigned asset URL captured in the previous minute keeps returning bytes for the rest of its 60 seconds. That is the deliberate trade for not proxying every byte of every asset through the app process. PRESIGN_TTL_SECONDS is configurable — lowering it shortens the window, at the cost of more redirects.

The same bound applies to a deleted artifact: rows and share links go immediately, objects go when the purge job runs past the retention window, and any presigned URL issued before the delete expires within its TTL.

Other controls

AreaWhat is done
Passwordsargon2id (m=19456, t=2, p=1). Sign-in is rate-limited per email and per IP, with one generic failure message that distinguishes nothing.
SessionsHttpOnly, Secure, SameSite=Lax, and host-only — no Domain attribute, so an artifact origin can never see the session cookie. Rotated on sign-in, revocable server-side.
Share and API tokens32 bytes of entropy, stored only as a SHA-256 hash. The plaintext is returned exactly once, at creation, and is unrecoverable afterwards.
User provider keysAES-256-GCM with ENCRYPTION_KEY. Never returned by any endpoint after being stored.
AuthorizationOne function (canRead) is the single read gate for every path, held to 100% branch coverage. Administrators are explicitly excluded from reading private artifacts.
Existence leaksAn unauthorized read is a 404, never a 403. That applies to artifacts, /setup after first run, and /signup without a redeemable invite.
Untrusted inputZod schemas on request bodies and on the environment. Model output goes through a dedicated incremental parser and bundle validator instead, both held to 100% branch coverage: paths are rejected for traversal, absolute paths, backslashes, double slashes, null bytes and disallowed extensions, and only complete file blocks are ever committed — prose outside a block or an unterminated final block persists nothing.
SQLDrizzle with parameterized queries throughout. No string-built SQL.
CSRFSameSite=Lax plus an origin check on state-changing requests.
Audit trailPrivacy changes, share creation and revocation, deletes, restores, purges, token and invite lifecycle, sign-ins and failures, and every non-private view including anonymous ones — with the IP. Rows survive artifact purge.
Log hygienePrompts, tokens, presigned URLs and Authorization headers are never logged. Prompts are never written to the audit log either.
Error hygieneNo stack traces, bucket names or file paths in any client-facing response.

Known limits

Stated plainly, because a limit you know about is not a vulnerability report:

  • Artifacts can reach the network.connect-src is * and three script CDNs are allowed, so a generated artifact can call out to the internet. There is no egress filtering. An artifact is code a user asked a model to write; treat it as such.
  • Rate limits and quotas are per process. They are held in memory, so a multi-replica deployment enforces them per replica rather than globally.
  • X-Forwarded-For is trusted. Every supported deployment puts a TLS-terminating proxy in front, so the first hop of that header is taken as the client IP. Expose the app process directly and the header becomes client-controlled: the per-IP sign-in rate limit can be bypassed and audit rows can be given arbitrary IPs. This is a deployment requirement, documented in docs/self-hosting.md, not a defect to report.
  • Wildcard TLS is your responsibility. Run it over plain http and the origin isolation this whole document rests on does not exist. The app warns at startup; it cannot refuse.
  • No published container image yet. Build from source and verify what you run.
  • v1 has never run in production. The privacy model is covered by 745 unit and integration tests and 95 browser specs, and by scripts/fresh-clone-demo.sh, which drives the whole authorization path end to end. It has no operational track record beyond that.

There aren't any published security advisories

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Security: datj9/enclave

Security

SECURITY.md

Security

Reporting a vulnerability

Report privately, through GitHub's private vulnerability reporting:

https://github.com/datj9/enclave/security/advisories/new

Do not open a public issue, and do not include a working exploit in anything public. If private reporting is unavailable on the repository, contact the maintainer through the address on their GitHub profile (https://github.com/datj9) and say only that you have a security report — details after a private channel is established.

Please include: what you did, what happened, what you expected, and the version or commit you tested. A proof of concept helps; a paragraph of prose about what is theoretically possible usually does not.

What to expect: this is a single-maintainer project with no support contract. You should get an acknowledgement within a week. There is no bug bounty and no reward beyond credit in the advisory, which you can decline. Once a fix is out, the advisory is published — please hold public disclosure until then.

Supported versions

v1 only. There are no earlier releases, and no backports.

The artifact isolation model

This is the part of enclave worth attacking, so here is exactly how it works and where its limits are. Artifacts are HTML, CSS and JavaScript written by a language model from a user's prompt. They are untrusted code that runs in your users' browsers, and the entire design follows from that.

One browser origin per artifact

Every artifact is served from its own hostname:

{artifactId}.artifacts.example.com

The artifactId is a UUID, and a host that merely looks like an artifact origin without a well-formed UUID in that position is not treated as one. The app itself lives on a different hostname, and nothing on the app origin is reachable from an artifact origin.

This is not cosmetic. Because each artifact has its own origin, the browser gives every artifact its own localStorage, IndexedDB, cookie jar and same-origin scope. Artifact A cannot read artifact B's stored data, because to the browser they are unrelated sites.

The sandbox, and why allow-same-origin is here

Artifacts render in an iframe on the app's viewer page:

sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"

allow-same-origin looks alarming in a sandbox attribute, and usually is. Without it, the browser puts the frame in an opaque origin, where localStorage and IndexedDB throw on access — a large share of generated artifacts store something, so they would simply break.

It is safe here for one specific reason: the origin the frame is allowed to be same-origin with is a hostname belonging to exactly one artifact, and that origin holds nothing worth stealing. The only cookie on it is a grant cookie scoped to that single artifact, whose payload is checked against the requested host on every read, so it is useless anywhere else. There is no session, no API access and no other artifact's data at that origin.

The dependency runs the other way too, and it is the one thing a future change must not break:

If the per-artifact origin scheme is ever changed so that several artifacts share a hostname, allow-same-origin must be removed in the same commit. A shared artifact origin plus allow-same-origin means every artifact can read every other artifact's stored data.

allow-scripts together with allow-same-origin also means the frame can remove its own sandbox attribute and reload — a documented browser behaviour, not a bug in enclave. It gains nothing by doing so, because the sandbox is not what isolates artifacts from each other or from the app; the origin is. The sandbox is defence in depth on top of it.

Content-Security-Policy

Two policies, emitted per host, because the two origins need opposite things.

Artifact origin permits 'unsafe-inline' and 'unsafe-eval' in script-src, plus three CDNs (esm.sh, cdn.jsdelivr.net, unpkg.com) so that React-via-import-map artifacts run at all. That is a real widening, and it is acceptable only because the origin holds nothing but the scoped grant cookie. It also sets frame-ancestors to the app origin alone, so no third-party site can frame a private artifact and read it through a user's browser, and base-uri 'none' with form-action 'none'.

App origin gets the opposite: a nonce-based script-src with 'strict-dynamic' and no'unsafe-inline' or 'unsafe-eval', frame-ancestors 'none', X-Frame-Options: DENY, HSTS, and nosniff.

These are set per-host in the request proxy rather than in next.config.ts, because a config-level header rule matches on path only and would put the app's X-Frame-Options: DENY on artifact responses — which would block the viewer's own iframe.

Content-Type on stored objects comes from the extension allowlist and is never sniffed.

Authorization, and the handoff

The artifact origin has no session and makes no authorization decisions of its own. Every decision happens on the app origin, and the artifact origin only trusts a signed token:

  1. A viewer opens /a/{id} (signed in, or with no session at all if the artifact is public) or /s/{token} (a share link) on the app origin.
  2. The app authorizes the read, then mints a handoff token: signed, single-use, 30-second lifetime, bound to a specific artifact, version and viewer.
  3. The viewer page frames https://{id}.artifacts.example.com/__enter?t=….
  4. /__enter validates the token, re-checks authorization — so a share revoked in the seconds since step 2 fails here — and sets a grant cookie for that subdomain only, 30-minute lifetime. Replaying the same token afterwards is refused and the token is burnt; a top-level replay is answered with a redirect to /a/{id}, which re-authorizes from scratch.
  5. The entry document is streamed from object storage through the app process, with authorization re-checked on every single request.
  6. Any other path is authorized from the grant cookie, resolved against the version's manifest by exact match, and answered with a redirect to a freshly minted presigned URL.

Every failure at or below the authorization check on the artifact origin — unauthorized, revoked, wrong host, unknown path — returns the same 404 with the same body. A grant miss above the first database call (no cookie, expired or forged grant, missing or burnt handoff token) is answered by intent: a top-level navigation gets 302 to {APP_URL}/a/{id}; a framed navigation gets a 404 re-entry page with a link to the same URL; a subresource stays the bare 404. That grant-miss answer is identical for any well-formed artifact host, whether or not the artifact exists — the no-existence-oracle invariant — because the origin has not yet consulted the database. Nothing at or below authorization distinguishes "exists but you may not read it" from "does not exist".

Revocation: instant for the document, ≤60 s for assets

This is the sharpest edge of the design, and worth stating precisely.

WhatRevocation delayWhy
The entry documentImmediateIt is proxied by the app on every request, and authorization is re-checked every time. Revoke a share link and the very next document load is a 404.
Assets (JS, CSS, images)Up to PRESIGN_TTL_SECONDS, default 60 secondsAsset bytes are served by object storage, not by the app, via presigned URLs. A URL already issued stays valid until it expires; enclave cannot recall it.

So: revoke a share link and the artifact stops loading at once, but a presigned asset URL captured in the previous minute keeps returning bytes for the rest of its 60 seconds. That is the deliberate trade for not proxying every byte of every asset through the app process. PRESIGN_TTL_SECONDS is configurable — lowering it shortens the window, at the cost of more redirects.

The same bound applies to a deleted artifact: rows and share links go immediately, objects go when the purge job runs past the retention window, and any presigned URL issued before the delete expires within its TTL.

Other controls

AreaWhat is done
Passwordsargon2id (m=19456, t=2, p=1). Sign-in is rate-limited per email and per IP, with one generic failure message that distinguishes nothing.
SessionsHttpOnly, Secure, SameSite=Lax, and host-only — no Domain attribute, so an artifact origin can never see the session cookie. Rotated on sign-in, revocable server-side.
Share and API tokens32 bytes of entropy, stored only as a SHA-256 hash. The plaintext is returned exactly once, at creation, and is unrecoverable afterwards.
User provider keysAES-256-GCM with ENCRYPTION_KEY. Never returned by any endpoint after being stored.
AuthorizationOne function (canRead) is the single read gate for every path, held to 100% branch coverage. Administrators are explicitly excluded from reading private artifacts.
Existence leaksAn unauthorized read is a 404, never a 403. That applies to artifacts, /setup after first run, and /signup without a redeemable invite.
Untrusted inputZod schemas on request bodies and on the environment. Model output goes through a dedicated incremental parser and bundle validator instead, both held to 100% branch coverage: paths are rejected for traversal, absolute paths, backslashes, double slashes, null bytes and disallowed extensions, and only complete file blocks are ever committed — prose outside a block or an unterminated final block persists nothing.
SQLDrizzle with parameterized queries throughout. No string-built SQL.
CSRFSameSite=Lax plus an origin check on state-changing requests.
Audit trailPrivacy changes, share creation and revocation, deletes, restores, purges, token and invite lifecycle, sign-ins and failures, and every non-private view including anonymous ones — with the IP. Rows survive artifact purge.
Log hygienePrompts, tokens, presigned URLs and Authorization headers are never logged. Prompts are never written to the audit log either.
Error hygieneNo stack traces, bucket names or file paths in any client-facing response.

Known limits

Stated plainly, because a limit you know about is not a vulnerability report:

  • Artifacts can reach the network.connect-src is * and three script CDNs are allowed, so a generated artifact can call out to the internet. There is no egress filtering. An artifact is code a user asked a model to write; treat it as such.
  • Rate limits and quotas are per process. They are held in memory, so a multi-replica deployment enforces them per replica rather than globally.
  • X-Forwarded-For is trusted. Every supported deployment puts a TLS-terminating proxy in front, so the first hop of that header is taken as the client IP. Expose the app process directly and the header becomes client-controlled: the per-IP sign-in rate limit can be bypassed and audit rows can be given arbitrary IPs. This is a deployment requirement, documented in docs/self-hosting.md, not a defect to report.
  • Wildcard TLS is your responsibility. Run it over plain http and the origin isolation this whole document rests on does not exist. The app warns at startup; it cannot refuse.
  • No published container image yet. Build from source and verify what you run.
  • v1 has never run in production. The privacy model is covered by 745 unit and integration tests and 95 browser specs, and by scripts/fresh-clone-demo.sh, which drives the whole authorization path end to end. It has no operational track record beyond that.

There aren't any published security advisories