--- title: server — security --- # security Rate limits, secret-scanner prefixes, audit events, anti-framing, CI-enforced invariants, log redaction, migration + coexistence notes. Companion: `tokens.md`, `middleware.md`, `device-flow.md`, `endpoints.md`. ## Rate limits | Endpoint | Limit | Scope | | ------------------------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `POST /openapi/v1/oauth/device/code` | 60 / hr / IP | Prevents device-code spam | | `POST /openapi/v1/oauth/device/token` | 1 / `interval` / device_code | Per RFC 8628 (`slow_down` on violation) | | `GET /openapi/v1/oauth/device/sso-initiate` | 60 / hr / IP | SSO-initiate flood protection | | `POST /openapi/v1/oauth/device/approve-external` | 10 / hr / subject_email | Mirror per-account limit on account-branch approve | | `POST /openapi/v1/oauth/device/approve` | 10 / hr / session | Account-branch approve | | `GET /openapi/v1/account` | 60 / min / account | Validation-call spam | | Bearer-authenticated requests on `/openapi/v1/*` | Per-token bucket | Default 60 req/min; `OPENAPI_RATE_LIMIT_PER_TOKEN` env. Shared Redis bucket per `sha256(token)` across instances | ## Secret-scanner prefixes Two distinct prefixes → two patterns. Assign severity per prefix: - `dfoa_[A-Za-z0-9_-]{43}` — high (full scope, account session) - `dfoe_[A-Za-z0-9_-]{43}` — medium (`apps:run` + `apps:read:permitted-external`, SSO-only surface) Coordinate with: - GitHub Advanced Security (push-protection partner program) - GitLab - BitBucket - TruffleHog Partner match → Dify endpoint receives leaked-token notification → revoke matching row + email owner. GitHub partner program approval can take weeks. Initiate early. ## Audit events | Event | Trigger | Payload | | --------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `oauth.device_flow_approved` | Device-flow approval success | `subject_email`, `account_id` nullable, `subject_issuer` (for External SSO), `client_id`, `device_label`, `scopes`, `subject_type` (`account` / `external_sso`), `rotated`, `expires_at`, `token_id` | | `oauth.device_flow_denied` | Explicit denial | `subject_email`, `client_id`, `device_label` | | `oauth.device_flow_rejected` | SSO branch email-collision reject (`sso-complete` or `approve-external`) | `subject_type`, `subject_email`, `subject_issuer`, `reason` | | `oauth.token_expired` | OAuth hard-expired on middleware hit | `token_id`, `subject`, `reason: "ttl"` | | `oauth.device_code_cross_ip_poll` | Device-flow poll succeeded from IP different from `/device/code` creation IP | `token_id`, `subject_email`, `creation_ip`, `poll_ip` | | `app.run.openapi` | `POST /openapi/v1/apps//run` or `POST /openapi/v1/permitted-external-apps//run` called | `app_id`, `tenant_id`, `subject` (subject_type + account_id or subject_email+issuer), `surface` (`apps` / `permitted-external-apps`), `source` (`oauth_account` / `oauth_sso`), `token_id` | | `openapi.wrong_surface_denied` | Surface gate rejected request (caller hit the surface for the other subject_type) | `subject_type`, `attempted_path`, `client_id`, `token_id` | `oauth.token_expired` fires from both Python middleware and EE inner-API resolve; idempotent CAS makes concurrent emit safe. See `tokens.md §Detection + hard-expire`. ## Inner API trust boundary Enterprise-svc inner endpoints serving the EE gateway: - `POST /inner/api/rbac/check-access` — workspace-role RBAC check - `POST /inner/api/auth/check-access-oauth` — token resolve Both authenticate via `Enterprise-Api-Secret-Key` header (= `INNER_API_KEY` env). Token tables accessed via `Skip: true` ent schemas in `pkg/data/dify/schema/` (read for resolve; write for hard-expire on `expires_at <= NOW()` only). ### Invariants | # | Invariant | | --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | Single env (`INNER_API_KEY`) gates every inner-API endpoint. No per-endpoint secrets. | | 2 | `/inner/api/*` MUST NOT be internet-facing. Caddyfiles, nginx, and helm configs MUST NOT proxy this path from the public ingress. | | 3 | Gateway MUST NOT inject resolved-identity headers downstream. Api always re-resolves from the token store. | | 4 | Enterprise svc inner-API performs the same hard-expire mutation as dify api Python middleware on `expires_at <= NOW()` (`UPDATE oauth_access_tokens SET revoked_at=NOW(), token_hash=NULL`, idempotent CAS, audit emit, Redis invalidate). | | 5 | EE deploys MUST keep `/openapi/v1/*` reachable only through the gateway. | CE deployments have no gateway: `/inner/api/auth/check-access-oauth` receives no traffic. Dify api Python middleware does its own resolve. ## Anti-framing Every response under `/openapi/v1/*` carries: ``` X-Frame-Options: DENY Content-Security-Policy: frame-ancestors 'none' ``` `/device` (Next.js) also emits the same pair. Without this, an attacker's page can iframe `/device` (SPA post-`sso_verified=1`) and UI-trick a victim with a valid `device_approval_grant` cookie into clicking Approve — functionally equivalent to CSRF, bypasses double-submit. Deny framing outright — no trusted embedder exists. ## Log redaction — device-flow secrets Flask access logs, request-body capture (debug mode), Sentry breadcrumbs, and any 3rd-party APM (Datadog, New Relic) capture request/response bodies by default. `device_code` and `user_code` travel plaintext on the routes listed below and **must not** land in any long-lived log store. **Routes carrying plaintext:** - `POST /openapi/v1/oauth/device/code` (response: `device_code`, `user_code`) - `POST /openapi/v1/oauth/device/token` (request: `device_code`) - `GET /openapi/v1/oauth/device/lookup` (query param: `user_code`) - `POST /openapi/v1/oauth/device/approve` (request: `user_code`) - `POST /openapi/v1/oauth/device/deny` (request: `user_code`) - `GET /openapi/v1/oauth/device/sso-initiate` (query: `user_code`) - `POST /openapi/v1/oauth/device/approve-external` (request: `user_code`) **Filter.** Register a Flask request/response log hook that redacts `device_code` + `user_code` fields from: - request body (JSON + form) - query string - response body (JSON) - Sentry breadcrumbs (`before_send` hook — replace values with `[REDACTED]`) - any structured-log emitter (e.g., `structlog` processors) Apply by **exact key-name match**, across every route (not route-scoped — cheap belt-and-braces). Same filter redacts `access_token` and `minted_token` keys. Minted OAuth plaintext (`dfoa_…`, `dfoe_…`) also covered: belt-and-braces, even though they normally travel only in `Authorization` headers. Rate-limit 503 / 4xx error bodies echo back `device_code` / `user_code` in some error shapes — filter covers those too (key-name match, not status-code scoped). ## Fingerprinting constraints - Never log full token at any layer. - Never log token hash to external system (hash is DB lookup key). - Audit events carry `token_id` (UUID), not hashes. ## Operator env vars | Var | Default | Effect | | --------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `ENABLE_OAUTH_BEARER` | `true` | Kill switch. `false` → `/openapi/v1/*` bearer routes return 503 `bearer_auth_disabled`. Legacy `app-` keys unaffected. | | `OAUTH_TTL_DAYS` | `14` | TTL applied to newly minted OAuth tokens. Range `[1, 365]`. | | `ENABLE_CLEAN_OAUTH_ACCESS_TOKENS_TASK` | `true` | Daily 05:00 retention sweep on `oauth_access_tokens`. | | `OAUTH_ACCESS_TOKEN_RETENTION_DAYS` | `30` | Rows where `revoked_at` OR `expires_at` is older than this are DELETEd by the retention task. | | `OPENAPI_KNOWN_CLIENT_IDS` | `"difyctl"` | Comma-separated allowlist of accepted `client_id` values at `/openapi/v1/oauth/device/code`. Unknown clients rejected. | | `OPENAPI_RATE_LIMIT_PER_TOKEN` | `60` | Per-token request budget per minute on bearer-authed `/openapi/v1/*` routes. Shared Redis bucket per `sha256(token)`. | ## Enterprise parity ### External SSO subjects EE SSO-verified identities mint `dfoe_` via `/device` SSO branch. `subject_email` populated, `account_id = NULL`, scopes `[apps:run, apps:read:permitted-external]`. Surface routing: `dfoe_` tokens reach only `/openapi/v1/permitted-external-apps*` (and `/openapi/v1/account` for identity readback). Surface gate (`@accept_subjects(USER_EXT_SSO)`) rejects `dfoe_` on the `/apps*` and `/workspaces*` surfaces with 403 `wrong_surface`. See `middleware.md §Surface gate`. ACL = binary access-mode gate. `app.access_mode ∈ {public, sso_verified}` only. No per-email whitelist, no group evaluation. No tenant concept — `dfoe_` is a global SSO identity; tenant resolved from each app row at request time. - SSO-only users can run any app with `access_mode` `public` or `sso_verified` (subject to `_apply_openapi_gate` `enable_api=true` filter). `internal` / `internal_all` apps invisible (filtered out of list, 404 on describe). - Admin restricts via IdP controls (who can authenticate) or access-mode toggles. - `subject_issuer` persists on `oauth_access_tokens` (surfaces on `auth devices list`, `GET /openapi/v1/account`, revoke-by-id) and travels on audit events. Not persisted on `end_users`. ### License / quota Bearer traffic attributed by `account_id` (account) or `subject_email` (External SSO), same way app-scoped-key traffic is attributed by tenant. EE-specific surface (`/permitted-external-apps*`) gated by license module — license absent / expired → 402 `license_required`. CE deploys skip license check (CE blueprint absence is the gate). Follows existing console/api license pattern; no new env var (reuses `ENTERPRISE_API_URL` + license helper).