Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ OVERROOT_DOCKER_IMAGE=overroot-tectonic:0.16.9
OVERROOT_DOCKER_CONTAINER=overroot-tectonic-worker
OVERROOT_FAKE_COMPILER=false

# Required shared account issuer and private backchannel config.
# Required site-owned account issuer and explicit private backchannel config.
OVERROOT_IDENTITY_ORIGIN=https://accounts.toni.foo
OVERROOT_IDENTITY_BASE_URL=http://127.0.0.1:18746
OVERROOT_IDENTITY_BASE_URL=http://127.0.0.1:18760
# Set OVERROOT_IDENTITY_CLIENT_SECRET privately (at least 32 characters). Never commit its value.

# Private administration URLs belong only to the site authority, never this frontend.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ Collaborative LaTeX studio. Public frontend, API and WebSockets: `https://overro
- Verify code with `pnpm check && pnpm test:compiler`. The latter uses real Docker to test isolated compiler access to nested private source files. Keep the production data directory private while preserving the isolated worker's staging traversal/read permissions.
- Persistent service changes must update the response check at `~/uptime/overroot.py` and any schedule checks. Liveness probes never compile documents or mutate app data.

- Human auth is a consumer of Space shared accounts, never local passwords. Read `deploy/SHARED-ACCOUNTS.md` before auth/config/migration work. Preserve explicit subject-to-local-ID mappings and local project/admin grants. Tests inject `IdentityProvider`; do not add production auth bypasses or infer identity from email/nickname. Agent Bearer credentials stay independent.
- Human auth consumes the site-owned accounts authority, never local passwords or Space-hosted authority. Read `deploy/SHARED-ACCOUNTS.md` before auth/config/migration work. Preserve explicit subject-to-local-ID mappings and local project grants and the legacy Admin-button flag. Require service_id `overroot` and live permission `use`; central roles never imply project permissions. Public account-admin APIs are removed; only issuer /admin navigation is public, with directory data behind exact internal Host + raw loopback + service key. Tests inject `IdentityProvider`; do not add production auth bypasses or infer identity from email/nickname. Agent Bearer credentials stay independent. Native http(s).request must preserve issuer Host on loopback; hosted tests capture real HTTP rather than mock fetch. Do not activate the ef15999 stage with the proven Host bug.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

Overroot serves its frontend, REST API, and collaborative WebSockets from one Node server on the DGX at `https://overroot.toni.foo`, through a dedicated named Cloudflare Tunnel. GitHub Actions checks changes before a host-side deployment timer can publish an immutable release. Docker is used only for isolated Tectonic compilation.

Overroot and Space share approved accounts at `https://accounts.toni.foo`. Public signup requires account-admin approval. Questions about using or hosting Overroot are welcome in [GitHub Issues](https://github.com/DebelToni/Overroot/issues).
Overroot and Space use site-owned accounts at `https://accounts.toni.foo`. Signup is immediate; access is requested and approved separately for each service. Questions about using or hosting Overroot are welcome in [GitHub Issues](https://github.com/DebelToni/Overroot/issues).

Overroot is independent and unaffiliated with Overleaf or Underleaf.ai.

Expand All @@ -45,11 +45,11 @@ Overroot is independent and unaffiliated with Overleaf or Underleaf.ai.

### 1. Sign in with your shared account

Select **Continue to shared accounts**. Existing Space passwords work at the central accounts origin only. New users can sign up there and wait for administrator approval for both apps. Overroot never asks for or stores that password.
Select **Continue to shared accounts**. Existing Space passwords are preserved by the explicit site account import and used only at the central origin. New users sign up there, request Overroot access, and wait for service approval. Space access is independent. Overroot never asks for or stores that password.

**Manage account** opens the central account controls. Signing out of Overroot revokes its app grant; central logout revokes grants derived from that identity session. A password change revokes other identity/app sessions. There is no password recovery, email delivery, or MFA in this flow.

Approval does not share projects or grant Overroot administrator access. Existing users retain their local IDs and permissions through an operator-supplied mapping, described in [shared account migration](deploy/SHARED-ACCOUNTS.md).
Service approval and registered roles do not share projects or change their owner/editor/viewer permissions. The preserved **Admin** button opens central `/admin`, which checks the configured owner and redirects to private, tailnet-only administration. Public account-admin data and mutation endpoints are unavailable, including to that owner. Existing users retain their local IDs and permissions through an operator-supplied mapping, described in [shared account migration](deploy/SHARED-ACCOUNTS.md).

### 2. Create and share a project

Expand Down Expand Up @@ -154,7 +154,7 @@ SQLite and generated runtime data live outside the source repository. Backup loc
pnpm dev
```

The HTTP Vite preview at http://localhost:5173 is suitable for UI work, but human sign-in requires a same-origin HTTPS setup with an exact callback registered by the provider. Configure `OVERROOT_PUBLIC_URL` for that HTTPS origin, proxy to Node preserving Host, and use the corresponding provider client registration. Do not disable Secure cookies or Origin checks for development. Tests inject a mocked identity provider; they do not require live Space endpoints.
The HTTP Vite preview at http://localhost:5173 is suitable for UI work, but human sign-in requires a same-origin HTTPS setup with an exact callback registered by the provider. Configure `OVERROOT_PUBLIC_URL` for that HTTPS origin, proxy to Node preserving Host, and use the corresponding provider client registration. Do not disable Secure cookies or Origin checks for development. Auth fixtures inject a v2 identity provider; hosted transport tests use a real loopback HTTP receiver to verify issuer Host, confidential client binding, response bounds, no redirects and the absolute timeout. They do not require a live authority.

Run every quality gate with:

Expand All @@ -166,7 +166,7 @@ This performs ESLint, strict TypeScript checks, backend/frontend tests, producti

## Security scope

Overroot is designed for approved accounts with private project memberships. Human passwords belong exclusively to the shared authority. App cookie and agent secrets are stored as hashes; WebSocket tickets are short-lived and single-use. Human API access introspects the central grant on every request and returns 503 during provider outages. Live human sockets revalidate every 10 seconds with a 3-second backchannel timeout, giving a target revocation/outage window of at most 13 seconds under a responsive event loop. Known revocation closes with 1008; provider failures close with retryable 1013. Upgrade tickets also revalidate their grant and project permissions. ZIP extraction and project paths are bounded, CORS is allowlisted, and LaTeX compilation is isolated from the host.
Overroot requires current service permission `use` plus explicit private project membership. Human passwords belong exclusively to the shared authority. App cookie and agent secrets are stored as hashes; WebSocket tickets are short-lived and single-use. Human API access introspects the subject-bound `overroot` service grant on every request and returns 503 during provider outages. Live human sockets revalidate every 10 seconds with a 3-second backchannel timeout, giving a target revocation/outage window of at most 13 seconds under a responsive event loop. Known revocation closes with 1008; provider failures close with retryable 1013. Upgrade tickets also revalidate their grant and project permissions. ZIP extraction and project paths are bounded, CORS is allowlisted, and LaTeX compilation is isolated from the host.

The production frontend and API share one origin behind the dedicated Cloudflare Tunnel. If the host or its network is unavailable, the service is unavailable while SQLite data remains on the host.

Expand Down
4 changes: 3 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ Supported code is the current `main` branch.

## Deployment boundaries

Shared accounts permit public signup but require administrator approval before either app can be used. Overroot project access remains explicit and private. Human credentials are handled only by the central Space authority at `accounts.toni.foo`; Overroot has no local-password fallback. Keep the server bound to loopback and expose it only through the dedicated Cloudflare Tunnel. Do not commit `.env`, runtime data, agent passwords, or backup snapshots.
Site-owned accounts permit immediate public signup, with separately requested/approved service access and registered roles. Overroot project access remains explicit and private. Human credentials are handled only by the site-owned authority at `accounts.toni.foo`; Overroot has no local-password fallback. Keep the server bound to loopback and expose it only through the dedicated Cloudflare Tunnel. Do not commit `.env`, runtime data, agent passwords, or backup snapshots.

Production builds and the app currently run as the DGX owner's account. Docker isolation applies only to LaTeX, not the application or deployment builds. A malicious merged change can access that account's files and Docker privileges. PR-only main, owner-only merging, exact-revision review and CI are a trust gate, not an operating-system sandbox. AI review can miss malicious code or prompt injection; a dedicated VM without owner credentials or the host Docker socket is needed for a stronger boundary. See [review policy](deploy/REVIEW.md).

Agent passwords are project-scoped and displayed once. Revoke a credential immediately if it may have leaked. Stop the server before restoring a database snapshot.

Shared account configuration, migration and revocation limits are documented in [the operator runbook](deploy/SHARED-ACCOUNTS.md). App cookies are host-only, Secure, HttpOnly and SameSite=Lax. Every cookie-authenticated mutation requires the exact app Origin; agent Authorization headers take precedence and retain independent project scope.

Public account-admin APIs are unavailable, even for the owner. The legacy local admin flag controls only an issuer-admin navigation button. Read-only local-directory access is restricted to actual loopback peers, exact Host `overroot.admin.internal`, and the confidential service client key; forwarded IP/Host headers and human cookies cannot authorize it. Every human grant must identify service `overroot` and retain permission `use`; central roles do not alter project ACLs.
33 changes: 20 additions & 13 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { config } from './config.js';
import type { AppContext } from './context.js';
import { Database } from './database.js';
import { AccountsProvider, type IdentityProvider } from './identity.js';
import { INTERNAL_USERS_PATH, registerInternalAdminRoutes, requireInternalAdmin } from './internal-admin.js';
import { HttpError } from './errors.js';
import { openapi } from './openapi.js';
import { ProjectStore } from './project-store.js';
Expand All @@ -33,7 +34,7 @@ export async function buildApp(options: BuildAppOptions = {}) {
const app = Fastify({
logger: options.logger === false ? false : {
level: process.env.LOG_LEVEL ?? 'info',
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.url', 'req.query', 'res.headers.location', 'res.headers["set-cookie"]', 'res.headers.authorization']
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.headers["x-accounts-client-key"]', 'req.url', 'req.query', 'res.headers.location', 'res.headers["set-cookie"]', 'res.headers.authorization']
},
trustProxy: config.trustProxy,
bodyLimit: 30 * 1024 * 1024,
Expand Down Expand Up @@ -73,6 +74,23 @@ export async function buildApp(options: BuildAppOptions = {}) {
return reply.code(500).send({ error: { code: 'internal_error', message: 'Internal server error' } });
});

app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/') && !request.routeOptions.url?.startsWith('/api/')) return;
if (request.method === 'GET' && request.routeOptions.url === INTERNAL_USERS_PATH) {
requireInternalAdmin(request);
reply.header('Cache-Control', 'no-store');
return;
}
if (request.headers.host !== new URL(config.publicUrl).host) throw new HttpError(403, 'Host is not allowed', 'host_denied');
reply.header('Cache-Control', 'no-store');
const bearer = request.headers.authorization !== undefined;
if (!bearer && !['GET', 'HEAD', 'OPTIONS'].includes(request.method) && request.headers.origin !== config.publicUrl) {
throw new HttpError(403, 'Origin is not allowed', 'origin_denied');
}
// Login callbacks must work even when an old app grant is revoked or unavailable.
if (!['/api/v1/auth/start', '/api/v1/auth/callback', '/api/v1/auth/account', '/api/v1/status'].includes(request.url.split('?')[0]!)) await auth.prepare(request);
});

await app.register(cors, {
origin(origin, callback) {
if (!origin || config.allowedOrigins.includes(origin)) callback(null, true);
Expand All @@ -88,18 +106,6 @@ export async function buildApp(options: BuildAppOptions = {}) {
limits: { files: 1, fileSize: 25 * 1024 * 1024, fields: 10 }
});

app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/')) return;
if (request.headers.host !== new URL(config.publicUrl).host) throw new HttpError(403, 'Host is not allowed', 'host_denied');
reply.header('Cache-Control', 'no-store');
const bearer = request.headers.authorization !== undefined;
if (!bearer && !['GET', 'HEAD', 'OPTIONS'].includes(request.method) && request.headers.origin !== config.publicUrl) {
throw new HttpError(403, 'Origin is not allowed', 'origin_denied');
}
// Login callbacks must work even when an old app grant is revoked or unavailable.
if (!['/api/v1/auth/start', '/api/v1/auth/callback', '/api/v1/auth/account', '/api/v1/status'].includes(request.url.split('?')[0]!)) await auth.prepare(request);
});

app.addHook('onSend', async (_request, reply, payload) => {
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('Referrer-Policy', 'no-referrer');
Expand All @@ -116,6 +122,7 @@ export async function buildApp(options: BuildAppOptions = {}) {
return reply.type('text/markdown').send(await readFile(path, 'utf8'));
});

registerInternalAdminRoutes(app, context);
registerAuthRoutes(app, context);
registerProjectRoutes(app, context);
registerFileRoutes(app, context);
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class AuthService {

createSession(grant: IdentityGrant): { token: string; user: PublicUser; maxAge: number } {
this.assertMigrated();
if (!grant.user.approved || grant.expires_at * 1000 <= Date.now()) throw new HttpError(401, 'Inactive account grant', 'inactive_grant');
if (grant.service_id !== 'overroot' || !grant.permissions.includes('use') || !grant.user.approved || grant.expires_at * 1000 <= Date.now()) throw new HttpError(401, 'Inactive account grant', 'inactive_grant');
return this.db.transaction(() => {
let user = this.db.get<UserRecord>('SELECT u.* FROM users u JOIN external_identities e ON e.user_id = u.id WHERE e.subject = ?', grant.user.id);
if (!user) {
Expand All @@ -56,7 +56,7 @@ export class AuthService {
const row = this.db.get<SessionRow>(`SELECT u.*, s.central_session_id, s.expires_at, e.subject FROM sessions s JOIN users u ON u.id = s.user_id JOIN external_identities e ON e.user_id = u.id WHERE s.token_hash = ?`, sessionHash);
if (!row?.central_session_id || row.expires_at <= new Date().toISOString()) return null;
const grant = await this.identity.introspect(row.central_session_id);
if (!grant.active || !grant.user.approved || grant.user.id !== row.subject || grant.expires_at * 1000 <= Date.now()) {
if (!grant.active || grant.service_id !== 'overroot' || !grant.permissions.includes('use') || !grant.user.approved || grant.user.id !== row.subject || grant.expires_at * 1000 <= Date.now()) {
this.db.run('DELETE FROM sessions WHERE token_hash = ?', sessionHash);
return null;
}
Expand Down
Loading
Loading