14 KiB
title
| 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/<id>/run or POST /openapi/v1/permitted-external-apps/<id>/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 checkPOST /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_sendhook — replace values with[REDACTED]) - any structured-log emitter (e.g.,
structlogprocessors)
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_modepublicorsso_verified(subject to_apply_openapi_gateenable_api=truefilter).internal/internal_allapps invisible (filtered out of list, 404 on describe). - Admin restricts via IdP controls (who can authenticate) or access-mode toggles.
subject_issuerpersists onoauth_access_tokens(surfaces onauth devices list,GET /openapi/v1/account, revoke-by-id) and travels on audit events. Not persisted onend_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).