-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy path.env.example
More file actions
343 lines (327 loc) · 17.4 KB
/
Copy path.env.example
File metadata and controls
343 lines (327 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# App
APP_ENV=development
APP_PORT=8080
LOG_LEVEL=warn
# Exposes /debug/pprof (heap, goroutine and allocation dumps, CPU/trace
# sampling). APP_ENV=development enables it implicitly, so a deployment that
# never sets APP_ENV gets the profiler by way of that default — always set
# APP_ENV explicitly in production and leave this false unless profiling.
PPROF_ENABLED=false
# Database
DB_HOST=localhost
DB_PORT=5432
DB_USER=sastlink
DB_PASSWORD=change_me
DB_NAME=sastlink
DB_SSLMODE=disable
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=change_me
REDIS_DB=0
REDIS_KEY_PREFIX=sastlink
# JWT / OAuth tokens
# JWT_SECRET_KEY is the active Ed25519 private key (PKCS#8 PEM). EdDSA signs
# ~10x faster than the RSA-2048 it replaced — a real cost on the 1c1g deployment
# where every login/refresh issues a JWT and every authenticated request verifies
# one. Generate one with:
# openssl genpkey -algorithm ED25519 -out jwt.key
# and paste the PEM body (with \n for newlines) into JWT_SECRET_KEY; the placeholder below is
# NOT a valid key and will fail at boot. JWT_SECRET_KEY_PREV may contain
# the previous Ed25519 public key and is used only to verify tokens during rotation.
JWT_SECRET_KEY="-----BEGIN PRIVATE KEY-----\nchange_me\n-----END PRIVATE KEY-----"
JWT_SECRET_KEY_PREV=
JWT_ACTIVE_KID=link-v2-active
JWT_PREVIOUS_KID=
# Must be an absolute http(s) URL: it is both the iss claim of every token and the
# base the OIDC discovery document builds its endpoint URLs from. A trailing slash is
# stripped at load, so discovery's issuer and the signed iss can never disagree.
JWT_ISSUER=https://link.sast.fun/v2
JWT_AUDIENCE=sast-link-v2
JWT_ACCESS_TOKEN_EXPIRY=1h
JWT_REFRESH_TOKEN_EXPIRY=720h
# Short-TTL per-token auth-state cache (replaces the JTI blacklist). A hit skips
# the per-request DB revocation check; revocation paths write a short-lived
# tombstone and the cache fill uses SET NX, so a stale pre-revocation entry
# cannot be re-seeded after a revocation. Fail-open: on a cache error the
# middleware falls back to the authoritative oauth_access_tokens.revoked_at query.
#
# This value does NOT bound the post-revocation window — the tombstone covers
# that at any TTL. What it bounds is a state change that does not revoke: an admin
# edit touching only "user".state, and account restore. Keep it short so that
# window stays small if a future check ever gates on state.
AUTH_STATE_CACHE_TTL=15s
REFRESH_TOKEN_HMAC_SECRET=replace_with_at_least_32_random_bytes
INTERNAL_OAUTH_CLIENT_ID=sast-link-web
# OAuth 2.1 / OIDC Provider
# Front-end consent page. GET /oauth/authorize validates the request and
# redirects here with ?request_id=...; the page then calls
# POST /oauth/authorize/consent with the logged-in user's access token.
# Required, no default: a guessed value would redirect users mid-flow to a page
# that does not exist.
OAUTH_CONSENT_URL=http://localhost:3000/oauth/consent
# Authorization code lifetime (PRD §4.10: 5min, single use).
OAUTH_CODE_TTL=5m
# How long a validated authorize request waits in Redis for the consent decision.
OAUTH_AUTHORIZE_REQUEST_TTL=10m
# SMTP (fill credentials in your local .env, never commit secrets)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-sender@example.com
SMTP_PASSWORD=your-app-password
SMTP_FROM=noreply@example.com
SMTP_USE_TLS=false
# Caps simultaneous SMTP sends; each send dials a fresh connection, so a
# verification-code burst beyond this queues instead of exhausting sockets.
SMTP_MAX_CONCURRENT=32
# CORS
CORS_ALLOWED_ORIGINS=http://localhost:3000
# Trusted reverse proxies (comma-separated CIDRs/IPs trusted to set X-Forwarded-For).
# Default trusts only loopback; set to your load balancer/CDN range in production.
TRUSTED_PROXIES=127.0.0.1,::1
# HSTS max-age in seconds (Strict-Transport-Security). Aligns with PRD default of 1 year.
HSTS_MAX_AGE=31536000
# Third-party login providers (SAST Link acting as an OAuth *client*, which is the
# opposite direction from the OAUTH_* provider settings above).
# Each provider is validated only when its ENABLED flag is true, so a deployment
# can run one, both, or neither. A disabled provider's routes stay registered and
# answer 400 rather than 404.
# The variables keep the FEISHU spelling while the API's provider enum, routes and
# identities.provider all use "lark".
#
# IMPORTANT — this process serves every route at the ROOT path. There is no /v2
# prefix anywhere in the Go code (internal/web/router.go creates a bare engine with
# no Group), so the backend answers /oauth/github/callback, not
# /v2/oauth/github/callback. The /v2 in JWT_ISSUER is the externally visible base a
# reverse proxy provides, and it is also what OIDC discovery concatenates endpoint
# URLs onto.
#
# The REDIRECT_URI values below must therefore be the EXTERNAL URL a provider can
# reach, including whatever prefix the proxy adds — not the in-cluster address.
# GitHub and Feishu both fetch it from the public internet, and both compare the
# same string again during the token exchange (RFC 6749 §4.1.3), so a value that
# only resolves inside the network fails at authorization time.
# Production example, with a proxy stripping /v2 before forwarding:
# OAUTH_GITHUB_REDIRECT_URI=https://link.sast.fun/v2/oauth/github/callback
OAUTH_FEISHU_ENABLED=false
OAUTH_FEISHU_CLIENT_ID=
OAUTH_FEISHU_CLIENT_SECRET=
# This is the callback URL registered with the Feishu app and where Feishu will
# redirect the browser after authorization. It must point at THIS backend, not the
# frontend: /oauth/lark/callback consumes the provider code and then 302s to one of
# the OAUTH_LOGIN_REDIRECTS allow-listed frontend URLs.
OAUTH_FEISHU_REDIRECT_URI=http://127.0.0.1:8080/oauth/lark/callback
# Required when OAUTH_FEISHU_ENABLED=true. PRD §4.5 limits Lark login to the SAST
# enterprise; an empty value would accept every tenant, so it is not optional.
OAUTH_FEISHU_TENANT_KEY=
OAUTH_GITHUB_ENABLED=false
OAUTH_GITHUB_CLIENT_ID=
OAUTH_GITHUB_CLIENT_SECRET=
# Same as above: GitHub redirects here after authorization; the backend callback
# lives at /oauth/github/callback.
#
# 127.0.0.1 rather than localhost, deliberately. GitHub's loopback exception is
# what lets one registered callback serve both this backend on :8080 and the
# frontend on :3000, and it is documented for 127.0.0.1 only — localhost does not
# get the port exemption. Verified locally: registering
# http://127.0.0.1/oauth covers both callbacks below.
OAUTH_GITHUB_REDIRECT_URI=http://127.0.0.1:8080/oauth/github/callback
# Frontend URLs a provider callback may redirect the browser back to, comma
# separated. Matched exactly: a prefix rule would turn the callback into an open
# redirector that hands out login codes. Required when either provider is enabled.
# Not registered with the provider — the provider never sees this value; it is
# where THIS backend 302s once the callback has been consumed.
OAUTH_LOGIN_REDIRECTS=http://127.0.0.1:3000/oauth/callback
# Frontend page a failed callback lands on, carrying ?error=&error_description=.
# When empty a failed callback answers with the JSON envelope instead.
OAUTH_LOGIN_ERROR_REDIRECT=http://127.0.0.1:3000/oauth/error
# TTLs for the three fail-closed Redis values this flow owns (PRD §6).
OAUTH_LOGIN_STATE_TTL=10m
OAUTH_LOGIN_REGISTRATION_STATE_TTL=15m
OAUTH_LOGIN_CODE_TTL=60s
# Binding callbacks (frontend routes, consumed by the frontend and the local test
# script — NOT read by this backend). When an already-authenticated user binds a
# third-party identity, the provider code must land on a frontend page that then
# calls POST /user/identities/{lark,github}; register these exact URLs in each
# provider app's redirect allow-list alongside the login callbacks above.
#
# Feishu accepts several redirect URLs per app, so its login and bind callbacks are
# simply two entries.
#
# GitHub allows exactly ONE "Authorization callback URL" per OAuth App, and matches
# it as: host excluding sub-domains and port must be exact, and the request's path
# must be a subdirectory of the registered path. Its own example table rejects
# http://example.com/ against a registered http://example.com/path, so the match is
# strictly "at or below", not merely "same host".
#
# That makes a single registered value cover both callbacks only when they share a
# registered parent path. Production therefore puts the bind page under the same
# /v2/oauth prefix as the login callback and registers that prefix:
#
# Authorization callback URL: https://link.sast.fun/v2/oauth
# login callback: https://link.sast.fun/v2/oauth/github/callback
# bind page (frontend): https://link.sast.fun/v2/oauth/bind/github
#
# This requires the reverse proxy to route /v2/oauth/bind/* to the frontend while
# every other /v2/* goes to the API. Caddy sorts handle blocks by path specificity,
# so the more specific bind matcher wins regardless of file order — see
# docs/runbooks/caddy-reverse-proxy.md for the Caddyfile and its verification steps.
#
# Do NOT register the bare root https://link.sast.fun. GitHub documents no example
# for a root registration, and it would let any path under the domain receive an
# authorization code.
#
# Registering a second OAuth App for binding does not work with this config: Bind()
# exchanges the code using OAUTH_GITHUB_CLIENT_ID/SECRET, so a code issued by a
# different App is rejected. That route would need a second client id/secret pair
# added here first.
#
# Local development uses GitHub's loopback exception instead of a reverse proxy: a
# callback registered as http://127.0.0.1/path ignores the port, so one value
# covers the API on :8080 and the frontend on :3000. Verified working with
#
# Authorization callback URL: http://127.0.0.1/oauth
#
# Use 127.0.0.1, not localhost — the exemption is documented for the literal
# loopback address only. The path rule still applies, which is why both callbacks
# sit under /oauth.
#
# This is a local-only shortcut. Production has no port exemption (both callbacks
# are on 443 anyway) and needs the proxy rule described above.
OAUTH_FEISHU_BIND_REDIRECT_URI=http://127.0.0.1:3000/oauth/bind/lark
OAUTH_GITHUB_BIND_REDIRECT_URI=http://127.0.0.1:3000/oauth/bind/github
# Object Storage (Tencent Cloud COS)
# Backs PUT /user/avatar (PRD §4.9). The group is optional: all empty means
# avatar upload is disabled and the endpoint answers 50002. A partially filled
# set is a startup error.
STORAGE_PROVIDER=cos
# Optional: overrides the bucket access host (bucket name included), e.g. an
# internal or CDN domain. Empty uses {bucket}.cos.{region}.myqcloud.com.
STORAGE_ENDPOINT=
# COS region of the bucket, e.g. ap-nanjing.
STORAGE_REGION=
# Bucket in {name}-{appid} form, e.g. sast-link-1250000000.
STORAGE_BUCKET=
# COS SecretId / SecretKey (must have PutObject/DeleteObject and, when the
# audit is on, the data-cos image review permission).
STORAGE_ACCESS_KEY=
STORAGE_SECRET_KEY=
# Optional: public URL prefix for stored avatars (typically a CDN domain).
# Empty falls back to the bucket access host.
STORAGE_BASE_URL=
# Content review for avatars (CI sensitive-content-recognition). On by default
# and fail-closed: while enabled, an unreachable review service rejects the
# upload. Disable only when the bucket has no data-cos capability.
STORAGE_AUDIT_ENABLED=true
# Avatar upload throttling is per caller.
RATE_LIMIT_UPLOAD_AVATAR_RPM=10
RATE_LIMIT_UPLOAD_AVATAR_WINDOW=60s
# Rate Limiting
# Every limiter below is per named endpoint and enforced in its service, not by a
# global middleware: a new route inherits no cap and must name one of its own.
#
# The per-IP defaults are tuned for the campus NAT reality: hundreds of users share
# one egress IP, so any per-IP cap must fit the whole campus's aggregate volume or
# login breaks during a rush. The login defense is the per-account lockout
# (LOGIN_FAILURE_LIMIT) below; per-IP login throttling is deliberately loose so the
# campus NAT is never the bottleneck. Abuse visibility is planned as an admin
# login-IP statistics feature rather than a per-IP cap.
RATE_LIMIT_LOGIN_RPM=300
RATE_LIMIT_LOGIN_WINDOW=15m
# Both send-email limiters share RATE_LIMIT_SEND_EMAIL_WINDOW: the counts below
# are per window, not per minute. Per target email, then per caller IP.
RATE_LIMIT_SEND_EMAIL_RPM=3
RATE_LIMIT_SEND_EMAIL_IP_RPM=30
RATE_LIMIT_SEND_EMAIL_WINDOW=60s
# Throttles POST /auth/register per Register-Ticket, not per IP: each accepted
# call runs one argon2id derivation, and the ticket is the
# one verified email that cost should be metered against. An IP key would put a
# whole campus NAT behind one counter during enrollment. The window must not
# exceed the ticket's 5-minute TTL — the service refuses to start if it does,
# because a throttled caller would have no live ticket left to retry with.
RATE_LIMIT_REGISTER_ATTEMPTS=5
RATE_LIMIT_REGISTER_WINDOW=5m
# Per-account login failure lockout: the counter is keyed by user ID (by the
# submitted identifier when no such account exists), never by IP. After this many
# failures in the window the account is locked for the rest of it. This is the
# real login defense, which is what lets the per-IP RATE_LIMIT_LOGIN_RPM above
# stay loose enough for a campus NAT to share.
LOGIN_FAILURE_LIMIT=10
LOGIN_FAILURE_WINDOW=15m
# Throttles DELETE /user/identities/:id per caller. Keyed by user, not by the
# address being unbound, so one user cannot lock out another who later binds the
# same address.
RATE_LIMIT_UNBIND_RPM=3
RATE_LIMIT_UNBIND_WINDOW=60s
# Throttles DELETE /user/devices/:id per user. The endpoint revokes a whole
# token family, killing a real session, so the cap is per authenticated user.
RATE_LIMIT_DEVICE_RPM=3
RATE_LIMIT_DEVICE_WINDOW=60s
# Throttles GET /oauth/authorize per caller IP. That endpoint is unauthenticated
# and writes one Redis stash key per call, so it needs a cap of its own.
RATE_LIMIT_AUTHORIZE_RPM=100
RATE_LIMIT_AUTHORIZE_WINDOW=60s
# Throttles POST /oauth/token and POST /oauth/revoke per caller IP. Both check
# client credentials and presented refresh tokens, so an unlimited rate means an
# unlimited number of credential attempts and DB round trips. Higher than the
# authorize cap: one authorization legitimately yields a token request plus
# periodic refreshes, and several clients can share an egress IP.
RATE_LIMIT_TOKEN_RPM=100
RATE_LIMIT_TOKEN_WINDOW=60s
# Throttles POST /auth/refresh per caller IP. The endpoint is unauthenticated and
# each call runs several DB statements, so without a cap a single source can
# amplify DB work for free. Same shape as the token endpoint above.
RATE_LIMIT_REFRESH_RPM=100
RATE_LIMIT_REFRESH_WINDOW=60s
# Throttles GET /oauth/github and GET /oauth/lark per caller IP. Same shape as
# /oauth/authorize — unauthenticated, one oauth_state key written per call — hence the
# same cap.
RATE_LIMIT_OAUTH_LOGIN_RPM=100
RATE_LIMIT_OAUTH_LOGIN_WINDOW=60s
# Throttles POST /oauth/exchange-code per caller IP. Unauthenticated by design,
# so without a cap the login_code space can be probed for free.
RATE_LIMIT_EXCHANGE_CODE_RPM=100
RATE_LIMIT_EXCHANGE_CODE_WINDOW=60s
# Throttles GET /card/{id} per caller IP. Unauthenticated with an enumerable path
# parameter, so an uncapped endpoint hands out a scrape of every public card. Set
# for a member wall behind a shared egress: a page rendering dozens of cards must
# not spend a whole NAT's minute on one visitor. A cap this loose slows a scrape
# rather than preventing it — bulk public-card reads belong behind the proxy cache.
RATE_LIMIT_CARD_RPM=300
RATE_LIMIT_CARD_WINDOW=60s
# Caps simultaneous argon2id derivations; also the memory ceiling (19 MiB each at
# the default parameters). Leave unset to follow GOMAXPROCS (1 on the 1c1g box);
# an explicitly empty value is treated the same as unset.
ARGON2_CONCURRENCY=
# argon2id is the only KDF for new password hashes. The default m=19456 KiB
# (19 MiB) / t=2 is the OWASP low-memory work factor adopted for the 1-core
# deployment. Raise ARGON2_MEMORY/TIME where offline strength matters,
# staying within the verify bounds (MEMORY ≤ 65536 KiB, TIME ≤ 10, THREADS ≤ 8).
ARGON2_TIME=2
ARGON2_MEMORY=19456
ARGON2_THREADS=1
# --- Data retention ---------------------------------------------------------
# The API process sweeps expired rows on a ticker; pg_cron is not used, because
# production has no such extension and loading one needs shared_preload_libraries
# plus a database restart. Multiple instances coordinate through a PostgreSQL
# advisory lock, so only one sweeps per tick.
RETENTION_INTERVAL=1h
# Rows deleted per statement. Batching keeps a long-neglected table from holding
# a share of the connection pool that live traffic needs.
RETENTION_BATCH_SIZE=1000
# Each age is measured back from now, so a row must have been dead for the whole
# window; the margin absorbs clock skew between the API and PostgreSQL.
RETENTION_AUTHORIZATION_AGE=1h
# Deliberately much wider than JWT_ACCESS_TOKEN_EXPIRY. The auth middleware
# answers an unknown JTI with the same 401 it uses for a revoked one, so deleting
# metadata while its JWT is still inside exp would present a merely expired token
# as revoked — the client reads a forced logout where it should have refreshed.
# Validation rejects a value below JWT_ACCESS_TOKEN_EXPIRY.
RETENTION_ACCESS_TOKEN_AGE=24h
# Only rotated-away tokens are swept, and never a family's first one: that row is
# what dates an ID Token's auth_time for as long as the family keeps rotating.
RETENTION_REFRESH_TOKEN_AGE=24h
# Defaults to the 90 days PRD §9 targets. Audit history here is operational rather
# than compliance-bound, so this may be raised, or trimmed as low as 720h (30 days);
# anything below that floor is rejected at startup.
RETENTION_AUDIT_LOG_AGE=2160h